Skip to main content

ephemeris_release/adapters/cargo/
semver_checks.rs

1//! Cargo API compatibility checks used by release proposal analysis.
2
3use std::io;
4use std::path::Path;
5use std::process::Command;
6
7use crate::version::Bump;
8
9/// A `cargo semver-checks` invocation failure distinct from a reported API
10/// break.
11#[derive(Debug, thiserror::Error)]
12pub(crate) enum SemverError {
13    /// The tool could not be started.
14    #[error("cannot run cargo semver-checks")]
15    Spawn(#[source] io::Error),
16    /// The tool exited with a code other than success or "breaking".
17    #[error("cargo semver-checks failed with status {code}: {stderr}")]
18    Failed {
19        /// The exit code.
20        code: i32,
21        /// The standard error output.
22        stderr: String,
23    },
24    /// The tool reported an API break without a recognizable summary line.
25    #[error("cargo semver-checks reported a break without a bump summary: {stderr}")]
26    MissingSummary {
27        /// The standard error output.
28        stderr: String,
29    },
30    /// The tool was killed by a signal.
31    #[error("cargo semver-checks terminated by signal: {stderr}")]
32    Signaled {
33        /// The standard error output.
34        stderr: String,
35    },
36}
37
38/// Check a package's public API against an earlier Cargo baseline tag and
39/// report the bump its changes require.
40///
41/// # Errors
42/// Returns an error when `cargo semver-checks` cannot run, reports a tool
43/// failure, or reports a break without a bump summary.
44pub(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
65/// The tool has no machine readable output, so a break (exit code 100) is
66/// classified by the summary line on standard error.
67pub(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}