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