Skip to main content

callisto_model/
exec.rs

1use std::path::Path;
2
3pub const REQUIRED_GIT: &str = ">=2.20";
4
5/// Trait for executing subprocess commands.
6pub trait CommandRunner: Send + Sync {
7    fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError>;
8}
9
10/// Output from executing a command.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct CommandOutput {
13    pub exit_code: Option<i32>,
14    pub stdout: String,
15    pub stderr: String,
16}
17
18impl CommandOutput {
19    pub fn success(&self) -> bool {
20        self.exit_code == Some(0)
21    }
22
23    pub fn stdout_trimmed(&self) -> &str {
24        self.stdout.trim()
25    }
26
27    pub fn stdout_lines(&self) -> impl Iterator<Item = &str> {
28        self.stdout
29            .lines()
30            .map(|l| l.trim())
31            .filter(|l| !l.is_empty())
32    }
33}
34
35/// Errors occurring during command execution.
36#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum CommandError {
39    #[error("`{program}` was not found; callisto requires it to be available")]
40    #[diagnostic(
41        code(E020),
42        help("Ensure program is installed and available on system PATH.")
43    )]
44    NotFound { program: String },
45
46    #[error("`{program}` reports version `{found}`, but callisto requires {required}")]
47    #[diagnostic(code(E021), help("Upgrade program to meet version requirement."))]
48    IncompatibleVersion {
49        program: String,
50        found: String,
51        required: String,
52    },
53
54    #[error("executing `{program}` is not supported on this surface: {reason}")]
55    #[diagnostic(code(E022))]
56    Unsupported { program: String, reason: String },
57
58    #[error("`{program}` failed with exit code {exit_code:?}: {stderr}")]
59    #[diagnostic(code(E023))]
60    Failed {
61        program: String,
62        exit_code: Option<i32>,
63        stderr: String,
64    },
65
66    #[error("failed to run `{program}`: {message}")]
67    #[diagnostic(code(E024))]
68    Io { program: String, message: String },
69}
70
71/// Validates git version against REQUIRED_GIT floor.
72pub fn check_git_version(reported: &str) -> Result<(), CommandError> {
73    let version_str = reported.trim();
74    let digits = version_str
75        .split_whitespace()
76        .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
77        .unwrap_or(version_str);
78
79    let parts: Vec<&str> = digits.split('.').collect();
80    if parts.len() < 2 {
81        return Err(CommandError::IncompatibleVersion {
82            program: "git".to_string(),
83            found: reported.to_string(),
84            required: REQUIRED_GIT.to_string(),
85        });
86    }
87
88    let major: u64 = parts[0].parse().unwrap_or(0);
89    let minor: u64 = parts[1].parse().unwrap_or(0);
90
91    if major < 2 || (major == 2 && minor < 20) {
92        return Err(CommandError::IncompatibleVersion {
93            program: "git".to_string(),
94            found: reported.to_string(),
95            required: REQUIRED_GIT.to_string(),
96        });
97    }
98
99    Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn parses_git_version() {
108        assert!(check_git_version("git version 2.39.5").is_ok());
109        assert!(check_git_version("git version 2.20.0").is_ok());
110        assert!(check_git_version("git version 1.8.5").is_err());
111    }
112}