xtask/cmd/semver_checks/
mod.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::process::Stdio;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7use regex::Regex;
8
9use crate::utils::{cargo_cmd, metadata};
10
11mod utils;
12use utils::{checkout_baseline, metadata_from_dir, workspace_crates_in_folder};
13
14#[derive(Debug, Clone, Parser)]
15pub struct SemverChecks {
16    /// Baseline git revision branch to compare against
17    #[clap(long, default_value = "main")]
18    baseline: String,
19
20    /// Disable hakari
21    #[clap(long, default_value = "false")]
22    disable_hakari: bool,
23}
24
25impl SemverChecks {
26    pub fn run(self) -> Result<()> {
27        println!("<details>");
28        println!("<summary> 🛫 Startup details 🛫 </summary>");
29        let current_metadata = metadata().context("getting current metadata")?;
30        let current_crates_set = workspace_crates_in_folder(&current_metadata, "crates");
31
32        let tmp_dir = PathBuf::from("target/semver-baseline");
33
34        // Checkout baseline (auto-cleanup on Drop)
35        let _worktree_cleanup = checkout_baseline(&self.baseline, &tmp_dir).context("checking out baseline")?;
36
37        let baseline_metadata = metadata_from_dir(&tmp_dir).context("getting baseline metadata")?;
38        let baseline_crates_set = workspace_crates_in_folder(&baseline_metadata, &tmp_dir.join("crates").to_string_lossy());
39
40        let common_crates: HashSet<_> = current_metadata
41            .packages
42            .iter()
43            .map(|p| p.name.clone())
44            .filter(|name| current_crates_set.contains(name) && baseline_crates_set.contains(name))
45            .collect();
46
47        let mut crates: Vec<_> = common_crates.iter().cloned().collect();
48        crates.sort();
49
50        println!("<details>");
51        println!("<summary> 📦 Processing crates 📦 </summary>");
52        // need to print an empty line for the bullet list to format correctly
53        println!();
54        for krate in crates {
55            println!("- `{krate}`");
56        }
57        // close crate details
58        println!("</details>");
59        // close startup details
60        println!("</details>");
61
62        if self.disable_hakari {
63            cargo_cmd().args(["hakari", "disable"]).status().context("disabling hakari")?;
64        }
65
66        let mut args = vec![
67            "semver-checks",
68            "check-release",
69            "--baseline-root",
70            tmp_dir.to_str().unwrap(),
71            "--all-features",
72        ];
73
74        for package in &common_crates {
75            args.push("--package");
76            args.push(package);
77        }
78
79        let output = cargo_cmd()
80            .env("CARGO_TERM_COLOR", "never")
81            .args(&args)
82            .stdout(Stdio::piped())
83            .stderr(Stdio::piped())
84            .output()
85            .context("running semver-checks")?;
86
87        let mut semver_output = String::new();
88        semver_output.push_str(&String::from_utf8_lossy(&output.stdout));
89        semver_output.push_str(&String::from_utf8_lossy(&output.stderr));
90
91        if semver_output.trim().is_empty() {
92            anyhow::bail!("No semver-checks output received. The command may have failed.");
93        }
94
95        // empty print to separate from startup details
96        println!();
97
98        // Regex to capture "Checking" lines (ignoring leading whitespace).
99        // Supports both formats:
100        //   "Checking <crate> vX.Y.Z (current)"
101        //   "Checking <crate> vX.Y.Z -> vX.Y.Z (no change)"
102        let check_re = Regex::new(r"^Checking\s+(?P<crate>\S+)\s+v(?P<curr>\d+\.\d+\.\d+)(?:\s+->\s+v\d+\.\d+\.\d+)?")
103            .context("compiling check regex")?;
104
105        // Regex for summary lines that indicate an update is required.
106        // Example:
107        //   "Summary semver requires new major version: 1 major and 0 minor checks failed"
108        let summary_re = Regex::new(r"^Summary semver requires new (?P<update_type>major|minor) version:")
109            .context("compiling summary regex")?;
110
111        let commit_hash = std::env::var("SHA").unwrap();
112        let scuffle_commit_url = format!("https://github.com/ScuffleCloud/scuffle/blob/{commit_hash}");
113
114        let mut current_crate: Option<(String, String)> = None;
115        let mut summary: Vec<String> = Vec::new();
116        let mut description: Vec<String> = Vec::new();
117        let mut error_count = 0;
118
119        let mut lines = semver_output.lines().peekable();
120        while let Some(line) = lines.next() {
121            let trimmed = line.trim_start();
122
123            if trimmed.starts_with("Checking") {
124                // Capture crate name and version without printing.
125                if let Some(caps) = check_re.captures(trimmed) {
126                    let crate_name = caps.name("crate").unwrap().as_str().to_string();
127                    let current_version = caps.name("curr").unwrap().as_str().to_string();
128                    current_crate = Some((crate_name, current_version));
129                }
130            } else if trimmed.starts_with("Summary") {
131                if let Some(caps) = summary_re.captures(trimmed) {
132                    let update_type = caps.name("update_type").unwrap().as_str();
133                    if let Some((crate_name, current_version)) = current_crate.take() {
134                        let new_version = new_version_number(&current_version, update_type)?;
135                        error_count += 1;
136
137                        // need to escape the #{error_count} otherwise it will refer to an actual pr
138                        summary.push(format!("### 🔖 Error `#{error_count}`"));
139                        summary.push(format!("⚠️ {update_type} update required for `{crate_name}`."));
140                        summary.push(format!(
141                            "🛠️ Please update the version from `v{current_version}` to `{new_version}`."
142                        ));
143
144                        summary.push("<details>".to_string());
145                        summary.push(format!("<summary> 📜 {crate_name} logs 📜 </summary>"));
146                        summary.append(&mut description);
147                        summary.push("</details>".to_string());
148
149                        // add a new line after the description
150                        summary.push("".to_string());
151                    }
152                }
153            } else if trimmed.starts_with("---") {
154                let mut is_failed_in_block = false;
155
156                for desc_line in lines.by_ref() {
157                    let desc_trimmed = desc_line.trim_start();
158
159                    if desc_trimmed.starts_with("Checking")
160                        || desc_trimmed.starts_with("Built")
161                        || desc_trimmed.starts_with("Building")
162                        || desc_trimmed.starts_with("Parsing")
163                        || desc_trimmed.starts_with("Parsed")
164                        || desc_trimmed.starts_with("Finished")
165                        || desc_trimmed.starts_with("Summary")
166                    {
167                        // sometimes an empty new line isn't detected before the description ends
168                        // in that case, add a closing `</details>` for the "Failed in" block.
169                        if is_failed_in_block {
170                            description.push("</details>".to_string());
171                        }
172                        break;
173                    } else if desc_trimmed.starts_with("Failed in:") {
174                        // create detail block for "Failed in" block
175                        is_failed_in_block = true;
176                        description.push("<details>".to_string());
177                        description.push("<summary> 🎈 Failed in the following locations 🎈 </summary>".to_string());
178                    } else if desc_trimmed.is_empty() && is_failed_in_block {
179                        // close detail close for "Failed in" block
180                        is_failed_in_block = false;
181                        description.push("</details>".to_string());
182                    } else if is_failed_in_block {
183                        // need new line to allow for bullet list formatting
184                        description.push("".to_string());
185
186                        let file_loc = desc_trimmed
187                            .split_whitespace()
188                            .last()
189                            .unwrap()
190                            .strip_prefix("/home/runner/work/scuffle/scuffle/")
191                            .unwrap()
192                            .replace(":", "#L");
193
194                        description.push(format!("- {scuffle_commit_url}/{file_loc}"));
195                    } else {
196                        description.push(desc_trimmed.to_string());
197                    }
198                }
199            }
200        }
201
202        // Print deferred update and failure block messages.
203        println!("# Semver-checks summary");
204        if error_count > 0 {
205            let s = if error_count == 1 { "" } else { "S" };
206            println!("\n### 🚩 {error_count} ERROR{s} FOUND 🚩");
207
208            // if there are 5+ errors, shrink the details by default.
209            if error_count >= 5 {
210                summary.insert(0, "<details>".to_string());
211                summary.insert(1, "<summary> 🦗 Open for error description 🦗 </summary>".to_string());
212                summary.push("</details>".to_string());
213            }
214
215            for line in summary {
216                println!("{line}");
217            }
218        } else {
219            println!("## ✅ No semver violations found! ✅");
220        }
221
222        // print an empty line to separate output from worktree cleanup line
223        println!();
224
225        Ok(())
226    }
227}
228
229fn new_version_number(version: &str, update_type: &str) -> Result<String> {
230    let version = version.strip_prefix('v').unwrap_or(version);
231    let mut parts: Vec<u64> = version
232        .split('.')
233        .map(|s| s.parse::<u64>())
234        .collect::<Result<_, _>>()
235        .context("parsing version numbers")?;
236    if parts.len() != 3 {
237        anyhow::bail!("expected version format vX.Y.Z, got: {version}");
238    }
239    match update_type {
240        "minor" => parts[2] += 1,
241        "major" => parts[1] += 1,
242        _ => anyhow::bail!("Failed to parse update type: {update_type}"),
243    }
244    Ok(format!("v{}.{}.{}", parts[0], parts[1], parts[2]))
245}