xtask/cmd/semver_checks/
mod.rs1use 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 #[clap(long, default_value = "main")]
20 baseline: String,
21
22 #[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(¤t_metadata, "crates");
33
34 let tmp_dir = PathBuf::from("target/semver-baseline");
35
36 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 println!("<summary> 📦 Processing crates 📦 </summary>\n");
55 for krate in crates {
56 println!("- `{krate}`");
57 }
58 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 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 println!("</details>\n");
110
111 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 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(¤t_version_str, update_type)?;
139
140 let update_type = format!("{}{}", update_type.chars().next().unwrap().to_uppercase(), &update_type[1..]);
142 error_count += 1;
143
144 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 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 if is_failed_in_block {
169 description.push("</details>".to_string());
170 }
171 break;
172 } else if desc_trimmed.starts_with("Failed in:") {
173 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 is_failed_in_block = false;
180 description.push("</details>".to_string());
181 } else if is_failed_in_block {
182 description.push("".to_string());
184
185 let file_loc = desc_trimmed
187 .split_whitespace()
188 .last() .unwrap();
190
191 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 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 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}