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}