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}