1use std::path::Path;
2
3pub const REQUIRED_GIT: &str = ">=2.20";
4
5pub trait CommandRunner: Send + Sync {
7 fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError>;
8}
9
10#[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#[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
71pub 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}