use std::io;
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub enum ToolProbe {
Available,
Unavailable,
ProbeFailed(io::Error),
}
pub fn on_path(name: &str) -> bool {
if name.contains('/') || name.contains('\\') {
return Path::new(name).exists();
}
let extensions: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
.split(';')
.filter(|e| !e.is_empty())
.map(|e| e.to_string())
.collect()
} else {
Vec::new()
};
if let Ok(path_var) = std::env::var("PATH") {
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return true;
}
for ext in &extensions {
let with_ext = dir.join(format!("{}{}", name, ext));
if with_ext.is_file() {
return true;
}
}
}
}
false
}
fn version_flag(name: &str) -> &'static str {
match name {
"ssh" => "-V",
"cosign" => "version",
_ => "--version",
}
}
pub fn runs(name: &str) -> ToolProbe {
match Command::new(name)
.arg(version_flag(name))
.current_dir(crate::path_util::probe_dir())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(status) if status.success() => ToolProbe::Available,
Ok(_) => ToolProbe::Unavailable,
Err(e) if e.kind() == io::ErrorKind::NotFound => ToolProbe::Unavailable,
Err(e) => ToolProbe::ProbeFailed(e),
}
}
const VERSION_SCAN_LINES: usize = 15;
fn extract_version_line(stdout: &str, stderr: &str) -> Option<String> {
let versionish = |text: &str| {
text.lines()
.take(VERSION_SCAN_LINES)
.map(str::trim)
.find(|line| !line.is_empty() && line.chars().any(|c| c.is_ascii_digit()))
.map(str::to_string)
};
versionish(stdout).or_else(|| versionish(stderr))
}
pub fn tool_version(name: &str) -> io::Result<Option<String>> {
let output = Command::new(name)
.arg(version_flag(name))
.current_dir(crate::path_util::probe_dir())
.output()?;
if output.status.success() {
Ok(extract_version_line(
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
))
} else {
Ok(None)
}
}
pub fn tool_runs_with_args(name: &str, args: &[&str]) -> bool {
Command::new(name)
.args(args)
.current_dir(crate::path_util::probe_dir())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn tool_runs_with_args_returns_true_for_existing_zero_exit_tool() {
assert!(tool_runs_with_args("true", &[]));
}
#[test]
fn tool_runs_with_args_returns_false_for_missing_binary() {
assert!(!tool_runs_with_args(
"nonexistent-binary-xyzzy",
&["--version"]
));
}
#[test]
fn version_flag_maps_ssh_to_dash_v() {
assert_eq!(version_flag("ssh"), "-V");
assert_eq!(version_flag("git"), "--version");
}
#[test]
fn runs_reports_present_tool_available() {
assert!(matches!(runs("git"), ToolProbe::Available));
}
#[test]
fn runs_folds_not_found_into_unavailable() {
assert!(matches!(
runs("this-tool-does-not-exist-12345"),
ToolProbe::Unavailable
));
}
#[test]
fn on_path_absolute_path_exists() {
if cfg!(windows) {
assert!(on_path("C:\\Windows\\System32\\cmd.exe"));
} else {
assert!(on_path("/usr/bin/env"));
}
}
#[test]
fn on_path_absolute_path_does_not_exist() {
if cfg!(windows) {
assert!(!on_path("C:\\nonexistent\\binary\\path.exe"));
} else {
assert!(!on_path("/nonexistent/binary/path"));
}
}
#[test]
fn on_path_bare_name_on_path() {
if cfg!(windows) {
assert!(on_path("cmd.exe"));
} else {
assert!(on_path("env"));
}
}
#[test]
fn on_path_bare_name_not_on_path() {
assert!(!on_path("nonexistent-binary-xyz-12345"));
}
#[test]
fn extract_version_line_skips_cosign_banner() {
let stdout = [
" ______ ______ _______. __ _______ .__ __.",
" / | / __ \\ / || | / _____|| \\ | |",
"| ,----'| | | | | (----`| | | | __ | \\| |",
"| `----.| `--' | .----) | | | | |__| | | |\\ |",
" \\______| \\______/ |_______/ |__| \\______| |__| \\__|",
"cosign: A tool for Container Signing, Verification and Storage in an OCI registry.",
"",
"GitVersion: v2.2.4",
"GitCommit: abc",
]
.join("\n");
assert_eq!(
extract_version_line(&stdout, ""),
Some("GitVersion: v2.2.4".to_string())
);
}
#[test]
fn extract_version_line_takes_normal_first_line() {
assert_eq!(
extract_version_line("git version 2.43.0\n", ""),
Some("git version 2.43.0".to_string())
);
assert_eq!(
extract_version_line("", "OpenSSH_9.6p1, OpenSSL 3.0.13\n"),
Some("OpenSSH_9.6p1, OpenSSL 3.0.13".to_string())
);
}
#[test]
fn extract_version_line_returns_none_without_digits() {
assert_eq!(extract_version_line("all prose, no version\n", ""), None);
assert_eq!(extract_version_line("", ""), None);
}
}