use std::path::Path;
use std::time::Duration;
pub const REQUIRED_GIT: &str = ">=2.20";
pub trait CommandRunner: Send + Sync {
fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError>;
fn run_with_timeout(
&self,
program: &str,
args: &[&str],
cwd: &Path,
_timeout: Duration,
) -> Result<CommandOutput, CommandError> {
self.run(program, args, cwd)
}
fn run_quiet(
&self,
program: &str,
args: &[&str],
cwd: &Path,
timeout: Duration,
) -> Result<CommandOutput, CommandError> {
self.run_with_timeout(program, args, cwd, timeout)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandOutput {
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl CommandOutput {
pub fn success(&self) -> bool {
self.exit_code == Some(0)
}
pub fn stdout_trimmed(&self) -> &str {
self.stdout.trim()
}
pub fn stdout_lines(&self) -> impl Iterator<Item = &str> {
self.stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
}
}
#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommandError {
#[error("`{program}` was not found; callisto requires it to be available")]
#[diagnostic(
code(E020),
help("Ensure program is installed and available on system PATH.")
)]
NotFound { program: String },
#[error("`{program}` reports version `{found}`, but callisto requires {required}")]
#[diagnostic(code(E021), help("Upgrade program to meet version requirement."))]
IncompatibleVersion {
program: String,
found: String,
required: String,
},
#[error("executing `{program}` is not supported on this surface: {reason}")]
#[diagnostic(code(E022))]
Unsupported { program: String, reason: String },
#[error("`{program}` failed with exit code {exit_code:?}: {stderr}")]
#[diagnostic(code(E023))]
Failed {
program: String,
exit_code: Option<i32>,
stderr: String,
},
#[error("failed to run `{program}`: {message}")]
#[diagnostic(code(E024))]
Io { program: String, message: String },
#[error("`{program}` timed out after {seconds}s")]
#[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."))]
TimedOut { program: String, seconds: u64 },
}
fn leading_digits(s: &str) -> &str {
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
&s[..end]
}
pub fn check_git_version(reported: &str) -> Result<(), CommandError> {
let incompatible = || CommandError::IncompatibleVersion {
program: "git".to_string(),
found: reported.to_string(),
required: REQUIRED_GIT.to_string(),
};
let version_str = reported.trim();
let digits = version_str
.split_whitespace()
.find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))
.unwrap_or(version_str);
let mut parts = digits.splitn(4, '.').take(3);
let major = leading_digits(parts.next().unwrap_or("0"));
let minor = leading_digits(parts.next().unwrap_or("0"));
let patch = leading_digits(parts.next().unwrap_or("0"));
let core = format!("{major}.{minor}.{patch}");
let version = semver::Version::parse(&core).map_err(|_parse_err| incompatible())?;
let required = semver::VersionReq::parse(REQUIRED_GIT).map_err(|_parse_err| incompatible())?;
if required.matches(&version) {
Ok(())
} else {
Err(incompatible())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_git_version() {
assert!(check_git_version("git version 2.39.5").is_ok());
assert!(check_git_version("git version 2.20.0").is_ok());
assert!(check_git_version("git version 1.8.5").is_err());
}
#[test]
fn rejects_versions_just_below_the_required_floor() {
assert!(check_git_version("git version 2.19.9").is_err());
assert!(check_git_version("git version 1.99.99").is_err());
}
#[test]
fn accepts_hyphenated_prerelease_suffix_above_the_floor() {
assert!(check_git_version("git version 2.39.5-rc1").is_ok());
assert!(check_git_version("git version 2.19.9-rc1").is_err());
}
#[test]
fn ignores_trailing_windows_suffix() {
assert!(check_git_version("git version 2.39.5.windows.1").is_ok());
assert!(check_git_version("git version 2.19.9.windows.1").is_err());
}
#[test]
fn treats_missing_patch_as_zero() {
assert!(check_git_version("git version 2.20").is_ok());
assert!(check_git_version("git version 2.19").is_err());
}
#[test]
fn rejects_unparseable_version_string() {
assert!(check_git_version("git version unknown").is_err());
assert!(check_git_version("").is_err());
}
}