Skip to main content

callisto_model/
exec.rs

1use std::path::Path;
2use std::time::Duration;
3
4/// The minimum `git` version callisto supports, as a [`semver::VersionReq`] grammar string
5/// consumed directly by [`check_git_version`] — the single place this floor is defined, so
6/// the requirement used for the actual comparison and the one rendered in
7/// [`CommandError::IncompatibleVersion`]'s message can never drift apart.
8pub const REQUIRED_GIT: &str = ">=2.20";
9
10/// Trait for executing subprocess commands.
11pub trait CommandRunner: Send + Sync {
12    fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError>;
13
14    /// Run a command with a hard wall-clock timeout. If the process does not
15    /// exit before `timeout` elapses it is killed and
16    /// [`CommandError::TimedOut`] is returned.
17    ///
18    /// The default implementation ignores `timeout` and delegates to [`Self::run`].
19    /// Implementors that control a real subprocess should override this with an
20    /// actual deadline check.
21    fn run_with_timeout(
22        &self,
23        program: &str,
24        args: &[&str],
25        cwd: &Path,
26        _timeout: Duration,
27    ) -> Result<CommandOutput, CommandError> {
28        self.run(program, args, cwd)
29    }
30
31    /// Like [`Self::run_with_timeout`], but for an internal existence/probe
32    /// check whose stderr looks like a failure on the common path (e.g.
33    /// `npm view` against an unpublished package prints a 404-shaped "not
34    /// found" to stderr normally) -- unlike a real, user-facing mutating
35    /// command, this must not stream that noise live to the terminal, only
36    /// capture it into [`CommandOutput`] for the caller to classify.
37    ///
38    /// Default implementation just delegates to [`Self::run_with_timeout`]
39    /// (streams live); implementors doing their own live-streaming should
40    /// override this to suppress it for probe calls.
41    fn run_quiet(
42        &self,
43        program: &str,
44        args: &[&str],
45        cwd: &Path,
46        timeout: Duration,
47    ) -> Result<CommandOutput, CommandError> {
48        self.run_with_timeout(program, args, cwd, timeout)
49    }
50}
51
52/// Output from executing a command.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct CommandOutput {
55    pub exit_code: Option<i32>,
56    pub stdout: String,
57    pub stderr: String,
58}
59
60impl CommandOutput {
61    pub fn success(&self) -> bool {
62        self.exit_code == Some(0)
63    }
64
65    pub fn stdout_trimmed(&self) -> &str {
66        self.stdout.trim()
67    }
68
69    pub fn stdout_lines(&self) -> impl Iterator<Item = &str> {
70        self.stdout.lines().map(|l| l.trim()).filter(|l| !l.is_empty())
71    }
72}
73
74/// Errors occurring during command execution.
75#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum CommandError {
78    #[error("`{program}` was not found; callisto requires it to be available")]
79    #[diagnostic(code(E020), help("Ensure program is installed and available on system PATH."))]
80    NotFound { program: String },
81
82    #[error("`{program}` reports version `{found}`, but callisto requires {required}")]
83    #[diagnostic(code(E021), help("Upgrade program to meet version requirement."))]
84    IncompatibleVersion {
85        program: String,
86        found: String,
87        required: String,
88    },
89
90    #[error("executing `{program}` is not supported on this surface: {reason}")]
91    #[diagnostic(code(E022))]
92    Unsupported { program: String, reason: String },
93
94    #[error("`{program}` failed with exit code {exit_code:?}: {stderr}")]
95    #[diagnostic(code(E023))]
96    Failed {
97        program: String,
98        exit_code: Option<i32>,
99        stderr: String,
100    },
101
102    #[error("failed to run `{program}`: {message}")]
103    #[diagnostic(code(E024))]
104    Io { program: String, message: String },
105
106    #[error("`{program}` timed out after {seconds}s")]
107    #[diagnostic(code(E025), help("The process did not exit within the allowed time. This usually indicates a network stall or a registry that is unreachable."))]
108    TimedOut { program: String, seconds: u64 },
109}
110
111/// Returns the leading run of ASCII digits in `s`, stopping at the first
112/// non-digit character (or the end of the string). Used to strip anything
113/// `semver` would otherwise interpret as pre-release/build metadata (a
114/// hyphen or plus) or a non-numeric trailing suffix, since a git version's
115/// own reported string was never intended to carry semver's meaning for
116/// those -- see [`check_git_version`]'s doc comment.
117fn leading_digits(s: &str) -> &str {
118    let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
119    &s[..end]
120}
121
122/// Validates git version against the [`REQUIRED_GIT`] floor.
123///
124/// `reported` is raw `git --version` output (e.g. `"git version 2.39.5"`,
125/// `"...2.39.5.windows.1"` on Windows, `"...2.39.5-rc1"` for a pre-release).
126/// Only the leading `major.minor.patch` triple is parsed, each component
127/// truncated at its first non-digit char (`5-rc1` -> `5`) before reaching
128/// `semver`: an unstripped hyphenated suffix would make `semver::VersionReq`
129/// exclude it from the `>=2.20` floor entirely, since semver reqs never
130/// match a pre-release unless the requirement itself carries a matching
131/// tag -- irrelevant to a real git build tag. Extra dot-separated
132/// components (`.windows.1`) are ignored; a missing minor/patch defaults
133/// to `0`.
134///
135/// Comparison itself is delegated to [`semver::VersionReq`] parsing
136/// [`REQUIRED_GIT`], so the floor lives in one place, not a hand-maintained
137/// duplicate that could drift from the error message's constant.
138pub fn check_git_version(reported: &str) -> Result<(), CommandError> {
139    let incompatible = || CommandError::IncompatibleVersion {
140        program: "git".to_string(),
141        found: reported.to_string(),
142        required: REQUIRED_GIT.to_string(),
143    };
144
145    let version_str = reported.trim();
146    let digits = version_str
147        .split_whitespace()
148        .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
149        .unwrap_or(version_str);
150
151    let mut parts = digits.splitn(4, '.').take(3);
152    let major = leading_digits(parts.next().unwrap_or("0"));
153    let minor = leading_digits(parts.next().unwrap_or("0"));
154    let patch = leading_digits(parts.next().unwrap_or("0"));
155    let core = format!("{major}.{minor}.{patch}");
156
157    let version = semver::Version::parse(&core).map_err(|_parse_err| incompatible())?;
158    let required = semver::VersionReq::parse(REQUIRED_GIT).map_err(|_parse_err| incompatible())?;
159
160    if required.matches(&version) {
161        Ok(())
162    } else {
163        Err(incompatible())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn parses_git_version() {
173        assert!(check_git_version("git version 2.39.5").is_ok());
174        assert!(check_git_version("git version 2.20.0").is_ok());
175        assert!(check_git_version("git version 1.8.5").is_err());
176    }
177
178    /// Boundary check: REQUIRED_GIT's floor is exactly 2.20 -- one patch
179    /// below and one minor below must both be rejected, matching the
180    /// version-requirement string itself rather than a separately
181    /// hand-maintained numeric comparison that could silently drift from it.
182    #[test]
183    fn rejects_versions_just_below_the_required_floor() {
184        assert!(check_git_version("git version 2.19.9").is_err());
185        assert!(check_git_version("git version 1.99.99").is_err());
186    }
187
188    /// Regression: `semver::VersionReq`'s `>=2.20` never matches a
189    /// pre-release version unless the requirement itself carries a matching
190    /// pre-release tag, which would make a hyphenated git build tag like
191    /// `-rc1` falsely reject an otherwise-satisfying version if passed
192    /// through to `semver::Version::parse` unstripped. A pre-release build
193    /// well above the floor must still be accepted.
194    #[test]
195    fn accepts_hyphenated_prerelease_suffix_above_the_floor() {
196        assert!(check_git_version("git version 2.39.5-rc1").is_ok());
197        assert!(check_git_version("git version 2.19.9-rc1").is_err());
198    }
199
200    /// Git for Windows appends a non-semver `.windows.N` suffix onto the
201    /// real version (e.g. `2.39.5.windows.1`). Only the leading
202    /// major.minor.patch triple must be parsed; the suffix must not cause a
203    /// spurious parse failure.
204    #[test]
205    fn ignores_trailing_windows_suffix() {
206        assert!(check_git_version("git version 2.39.5.windows.1").is_ok());
207        assert!(check_git_version("git version 2.19.9.windows.1").is_err());
208    }
209
210    /// A truncated report (major.minor with no patch) must not be rejected
211    /// outright -- the missing patch is treated as 0, same as the original
212    /// hand-rolled comparison's behavior.
213    #[test]
214    fn treats_missing_patch_as_zero() {
215        assert!(check_git_version("git version 2.20").is_ok());
216        assert!(check_git_version("git version 2.19").is_err());
217    }
218
219    /// Garbage input must be rejected as incompatible, not silently
220    /// defaulted to 0 and evaluated as if it were a real (if very old)
221    /// version -- there is a real difference between "we don't know what
222    /// version this is" and "this version is definitely too old", even
223    /// though both currently produce the same IncompatibleVersion error.
224    #[test]
225    fn rejects_unparseable_version_string() {
226        assert!(check_git_version("git version unknown").is_err());
227        assert!(check_git_version("").is_err());
228    }
229}