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
186 .split_whitespace()
187 .last()
188 .unwrap()
189 .strip_prefix("/home/runner/work/scuffle/scuffle/")
190 .unwrap()
191 .replace(":", "#L");
192
193 description.push(format!("- {scuffle_commit_url}/{file_loc}"));
194 } else {
195 description.push(desc_trimmed.to_string());
196 }
197
198 lines.next();
199 }
200 }
201 }
202
203 println!("# Semver-checks summary");
205 if error_count > 0 {
206 let s = if error_count == 1 { "" } else { "S" };
207 println!("\n### 🚩 {error_count} ERROR{s} FOUND 🚩");
208
209 if error_count >= 5 {
211 summary.insert(0, "<details>".to_string());
212 summary.insert(1, "<summary> 🦗 Open for error description 🦗 </summary>".to_string());
213 summary.push("</details>".to_string());
214 }
215
216 for line in summary {
217 println!("{line}");
218 }
219 } else {
220 println!("## ✅ No semver violations found! ✅");
221 }
222
223 Ok(())
224 }
225}
226
227fn new_version_number(crate_version: &str, update_type: &str) -> Result<Version> {
228 let update_is_major = update_type.eq_ignore_ascii_case("major");
229
230 let version_stripped = crate_version.strip_prefix('v').unwrap();
231 let version_parsed = Version::parse(version_stripped)?;
232
233 let bumped = if update_is_major {
234 major_update(&version_parsed)
235 } else {
236 minor_update(&version_parsed)
237 };
238
239 Ok(bumped)
240}
241
242fn major_update(current_version: &Version) -> Version {
243 if !current_version.pre.is_empty() {
244 current_version.increment_prerelease()
245 } else if current_version.major == 0 && current_version.minor == 0 {
246 current_version.increment_patch()
247 } else if current_version.major == 0 {
248 current_version.increment_minor()
249 } else {
250 current_version.increment_major()
251 }
252}
253
254fn minor_update(current_version: &Version) -> Version {
255 if !current_version.pre.is_empty() {
256 current_version.increment_prerelease()
257 } else if current_version.major == 0 {
258 current_version.increment_minor()
259 } else {
260 current_version.increment_patch()
261 }
262}