ephemeris_release/adapters/cargo/
semver_checks.rs1use std::io;
4use std::path::Path;
5use std::process::Command;
6
7use crate::version::Bump;
8
9#[derive(Debug, thiserror::Error)]
12pub(crate) enum SemverError {
13 #[error("cannot run cargo semver-checks")]
15 Spawn(#[source] io::Error),
16 #[error("cargo semver-checks failed with status {code}: {stderr}")]
18 Failed {
19 code: i32,
21 stderr: String,
23 },
24 #[error("cargo semver-checks reported a break without a bump summary: {stderr}")]
26 MissingSummary {
27 stderr: String,
29 },
30 #[error("cargo semver-checks terminated by signal: {stderr}")]
32 Signaled {
33 stderr: String,
35 },
36}
37
38pub(crate) fn required_bump(root: &Path, package: &str, tag: &str) -> Result<Bump, SemverError> {
45 let output = Command::new("cargo")
46 .args([
47 "semver-checks",
48 "check-release",
49 "--package",
50 package,
51 "--baseline-rev",
52 tag,
53 "--color",
54 "never",
55 ])
56 .current_dir(root)
57 .output()
58 .map_err(SemverError::Spawn)?;
59 classify_status(
60 output.status.code(),
61 &String::from_utf8_lossy(&output.stderr),
62 )
63}
64
65pub(crate) fn classify_status(code: Option<i32>, stderr: &str) -> Result<Bump, SemverError> {
68 match code {
69 Some(0) => Ok(Bump::Patch),
70 Some(100) => summary_bump(stderr).ok_or_else(|| SemverError::MissingSummary {
71 stderr: stderr.to_owned(),
72 }),
73 Some(code) => Err(SemverError::Failed {
74 code,
75 stderr: stderr.to_owned(),
76 }),
77 None => Err(SemverError::Signaled {
78 stderr: stderr.to_owned(),
79 }),
80 }
81}
82
83fn summary_bump(stderr: &str) -> Option<Bump> {
84 stderr.lines().find_map(|line| {
85 let kind = line
86 .trim()
87 .strip_prefix("Summary semver requires new ")?
88 .split_whitespace()
89 .next()?;
90 match kind {
91 "major" => Some(Bump::Major),
92 "minor" => Some(Bump::Minor),
93 _ => None,
94 }
95 })
96}
97
98#[cfg(test)]
99mod tests {
100 use super::{SemverError, classify_status};
101 use crate::version::Bump;
102
103 const MAJOR: &str = " Checked [ 0.006s] 202 checks: 201 pass, 1 fail, 0 warn, 58 \
104 skip\n\n Summary semver requires new major version: 1 major and 0 \
105 minor checks failed\n Finished [ 1.764s] lib1\n";
106 const MINOR: &str =
107 " Summary semver requires new minor version: 0 major and 1 minor checks failed\n";
108
109 #[test]
110 fn success_requires_a_patch() {
111 assert_eq!(
112 classify_status(Some(0), " Summary no semver update required\n").unwrap(),
113 Bump::Patch
114 );
115 }
116
117 #[test]
118 fn break_reads_the_bump_from_the_summary() {
119 assert_eq!(classify_status(Some(100), MAJOR).unwrap(), Bump::Major);
120 assert_eq!(classify_status(Some(100), MINOR).unwrap(), Bump::Minor);
121 }
122
123 #[test]
124 fn break_without_a_known_summary_is_an_error() {
125 assert!(matches!(
126 classify_status(Some(100), "Summary semver requires new epoch version"),
127 Err(SemverError::MissingSummary { stderr }) if stderr.contains("epoch")
128 ));
129 assert!(matches!(
130 classify_status(Some(100), ""),
131 Err(SemverError::MissingSummary { stderr: _ })
132 ));
133 }
134
135 #[test]
136 fn semver_checker_distinguishes_breaking_changes_from_tool_failures() {
137 assert!(matches!(
138 classify_status(Some(101), "rustdoc failed"),
139 Err(SemverError::Failed { stderr, code: _ }) if stderr.contains("rustdoc failed")
140 ));
141 assert!(matches!(
142 classify_status(Some(127), "missing command"),
143 Err(SemverError::Failed { stderr, code: _ }) if stderr.contains("missing command")
144 ));
145 assert!(matches!(
146 classify_status(None, "killed"),
147 Err(SemverError::Signaled { stderr }) if stderr == "killed"
148 ));
149 }
150}