xtask/cmd/semver_checks/
mod.rs

1use std::collections::HashSet;
2use std::io::Read;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7use next_version::NextVersion;
8use regex::Regex;
9use semver::Version;
10
11use crate::utils::{cargo_cmd, metadata};
12
13mod utils;
14use utils::{checkout_baseline, metadata_from_dir, workspace_crates_in_folder};
15
16#[derive(Debug, Clone, Parser)]
17pub struct SemverChecks {
18    /// Baseline git revision branch to compare against
19    #[clap(long, default_value = "main")]
20    baseline: String,
21
22    /// Disable hakari
23    #[clap(long, default_value = "false")]
24    disable_hakari: bool,
25}
26
27impl SemverChecks {
28    pub fn run(self) -> Result<()> {
29        println!("<details>");
30        println!("<summary> 🛫 Startup details 🛫 </summary>");
31        let current_metadata = metadata().context("getting current metadata")?;
32        let current_crates_set = workspace_crates_in_folder(&current_metadata, "crates");
33
34        let tmp_dir = PathBuf::from("target/semver-baseline");
35
36        // Checkout baseline (auto-cleanup on Drop)
37        let _worktree_cleanup = checkout_baseline(&self.baseline, &tmp_dir).context("checking out baseline")?;
38
39        let baseline_metadata = metadata_from_dir(&tmp_dir).context("getting baseline metadata")?;
40        let baseline_crates_set = workspace_crates_in_folder(&baseline_metadata, &tmp_dir.join("crates").to_string_lossy());
41
42        let common_crates: HashSet<_> = current_metadata
43            .packages
44            .iter()
45            .map(|p| p.name.clone())
46            .filter(|name| current_crates_set.contains(name) && baseline_crates_set.contains(name))
47            .collect();
48
49        let mut crates: Vec<_> = common_crates.iter().cloned().collect();
50        crates.sort();
51
52        println!("<details>");
53        // need an extra empty line for the bullet list to format correctly
54        println!("<summary> 📦 Processing crates 📦 </summary>\n");
55        for krate in crates {
56            println!("- `{krate}`");
57        }
58        // close crate details
59        println!("</details>");
60
61        if self.disable_hakari {
62            cargo_cmd().args(["hakari", "disable"]).status().context("disabling hakari")?;
63        }
64
65        let mut args = vec![
66            "semver-checks",
67            "check-release",
68            "--baseline-root",
69            tmp_dir.to_str().unwrap(),
70            "--all-features",
71        ];
72
73        for package in &common_crates {
74            args.push("--package");
75            args.push(package);
76        }
77
78        let mut command = cargo_cmd();
79        command.env("CARGO_TERM_COLOR", "never");
80        command.args(&args);
81
82        let (mut reader, writer) = os_pipe::pipe()?;
83        let writer_clone = writer.try_clone()?;
84        command.stdout(writer);
85        command.stderr(writer_clone);
86
87        let mut handle = command.spawn()?;
88
89        drop(command);
90
91        let mut semver_output = String::new();
92        reader.read_to_string(&mut semver_output)?;
93        handle.wait()?;
94
95        if semver_output.trim().is_empty() {
96            anyhow::bail!("No semver-checks output received. The command may have failed.");
97        }
98
99        // save the original output for debugging purposes
100        println!("<details>");
101        println!("<summary> Original semver output: </summary>\n");
102        for line in semver_output.lines() {
103            println!("{line}");
104        }
105        println!("</details>");
106
107        // close startup details
108        // extra line to separate from startup details
109        println!("</details>\n");
110
111        // Regex for summary lines that indicate an update is required.
112        // Example:
113        //   "Summary semver requires new major version: 1 major and 0 minor checks failed"
114        let summary_re = Regex::new(r"^Summary semver requires new (?P<update_type>major|minor) version:")
115            .context("compiling summary regex")?;
116
117        let commit_hash = std::env::var("SHA")?;
118        let scuffle_commit_url = format!("https://github.com/ScuffleCloud/scuffle/blob/{commit_hash}");
119
120        let mut current_crate: Option<(String, String)> = None;
121        let mut summary: Vec<String> = Vec::new();
122        let mut description: Vec<String> = Vec::new();
123        let mut error_count = 0;
124
125        let mut lines = semver_output.lines().peekable();
126        while let Some(line) = lines.next() {
127            let trimmed = line.trim_start();
128
129            if trimmed.starts_with("Checking") {
130                // example line: Checking nutype-enum v0.1.2 -> v0.1.2 (no change)
131                // sometimes the (no change) part is missing if the crate has already been updated.
132                let split_line = trimmed.split_whitespace().collect::<Vec<_>>();
133                current_crate = Some((split_line[1].to_string(), split_line[2].to_string()));
134            } else if trimmed.starts_with("Summary") {
135                if let Some(summary_line) = summary_re.captures(trimmed) {
136                    let (crate_name, current_version_str) = current_crate.take().unwrap();
137                    let update_type = summary_line.name("update_type").unwrap().as_str();
138                    let new_version = new_version_number(&current_version_str, update_type)?;
139
140                    // capitalize first letter of update_type
141                    let update_type = format!("{}{}", update_type.chars().next().unwrap().to_uppercase(), &update_type[1..]);
142                    error_count += 1;
143
144                    // need to escape the #{error_count} otherwise it will refer to an actual pr
145                    summary.push(format!("### 🔖 Error `#{error_count}`"));
146                    summary.push(format!("{update_type} update required for `{crate_name}` ⚠️"));
147                    summary.push(format!(
148                        "Please update the version from `{current_version_str}` to `v{new_version}` 🛠️"
149                    ));
150
151                    summary.push("<details>".to_string());
152                    summary.push(format!("<summary> 📜 {crate_name} logs 📜 </summary>\n"));
153                    summary.append(&mut description);
154                    summary.push("</details>".to_string());
155
156                    // add a new line after the description
157                    summary.push("".to_string());
158                }
159            } else if trimmed.starts_with("---") {
160                let mut is_failed_in_block = false;
161
162                while let Some(desc_line) = lines.peek() {
163                    let desc_trimmed = desc_line.trim_start();
164
165                    if desc_trimmed.starts_with("Summary") {
166                        // sometimes an empty new line isn't detected before the description ends
167                        // in that case, add a closing `</details>` for the "Failed in" block.
168                        if is_failed_in_block {
169                            description.push("</details>".to_string());
170                        }
171                        break;
172                    } else if desc_trimmed.starts_with("Failed in:") {
173                        // create detail block for "Failed in" block
174                        is_failed_in_block = true;
175                        description.push("<details>".to_string());
176                        description.push("<summary> 🎈 Failed in the following locations 🎈 </summary>".to_string());
177                    } else if desc_trimmed.is_empty() && is_failed_in_block {
178                        // close detail close for "Failed in" block
179                        is_failed_in_block = false;
180                        description.push("</details>".to_string());
181                    } else if is_failed_in_block {
182                        // need new line to allow for bullet list formatting
183                        description.push("".to_string());
184
185                        // at this point, we begin parsing the
186                        let file_loc = desc_trimmed
187                            .split_whitespace()
188                            .last() // get the file location string (the last string in the line)
189                            .unwrap();
190
191                        // remove the prefix if it exists, otherwise use the original string
192                        // for reference, the Some case would be something like:
193                        // field stdout of struct CompileOutput, previously in file "/home/runner/work/scuffle/scuffle/..."
194                        // but the other case would be something like:
195                        // "feature prettyplease in the package's Cargo.toml"
196                        match file_loc.strip_prefix(&format!("{}/", current_metadata.workspace_root)) {
197                            Some(stripped) => {
198                                let file_loc = stripped.replace(":", "#L");
199                                description.push(format!("- {scuffle_commit_url}/{file_loc}"));
200                            }
201                            None => {
202                                description.push(format!("- {desc_trimmed}"));
203                            }
204                        };
205                    } else {
206                        description.push(desc_trimmed.to_string());
207                    }
208
209                    lines.next();
210                }
211            }
212        }
213
214        // Print deferred update and failure block messages.
215        println!("# Semver-checks summary");
216        if error_count > 0 {
217            let s = if error_count == 1 { "" } else { "S" };
218            println!("\n### 🚩 {error_count} ERROR{s} FOUND 🚩");
219
220            // if there are 5+ errors, shrink the details by default.
221            if error_count >= 5 {
222                summary.insert(0, "<details>".to_string());
223                summary.insert(1, "<summary> 🦗 Open for error description 🦗 </summary>".to_string());
224                summary.push("</details>".to_string());
225            }
226
227            for line in summary {
228                println!("{line}");
229            }
230        } else {
231            println!("## ✅ No semver violations found! ✅");
232        }
233
234        Ok(())
235    }
236}
237
238fn new_version_number(crate_version: &str, update_type: &str) -> Result<Version> {
239    let update_is_major = update_type.eq_ignore_ascii_case("major");
240
241    let version_stripped = crate_version.strip_prefix('v').unwrap();
242    let version_parsed = Version::parse(version_stripped)?;
243
244    let bumped = if update_is_major {
245        major_update(&version_parsed)
246    } else {
247        minor_update(&version_parsed)
248    };
249
250    Ok(bumped)
251}
252
253fn major_update(current_version: &Version) -> Version {
254    if !current_version.pre.is_empty() {
255        current_version.increment_prerelease()
256    } else if current_version.major == 0 && current_version.minor == 0 {
257        current_version.increment_patch()
258    } else if current_version.major == 0 {
259        current_version.increment_minor()
260    } else {
261        current_version.increment_major()
262    }
263}
264
265fn minor_update(current_version: &Version) -> Version {
266    if !current_version.pre.is_empty() {
267        current_version.increment_prerelease()
268    } else if current_version.major == 0 {
269        current_version.increment_minor()
270    } else {
271        current_version.increment_patch()
272    }
273}