Skip to main content

ralph/commands/runner/
detection.rs

1//! Runner binary detection utilities.
2//!
3//! Responsibilities:
4//! - Check if runner binaries are installed and accessible.
5//! - Extract version strings from runner binaries.
6//!
7//! Not handled here:
8//! - Capability data (see capabilities.rs).
9//! - CLI output formatting.
10
11use std::process::Command;
12
13use anyhow::Context;
14
15/// Result of checking a runner binary.
16#[derive(Debug, Clone)]
17pub struct BinaryStatus {
18    /// Whether the binary was found and executable.
19    pub installed: bool,
20    /// Version string if available.
21    pub version: Option<String>,
22    /// Error message if check failed.
23    pub error: Option<String>,
24}
25
26/// Check if a runner binary is installed by trying common version/help flags.
27///
28/// Tries the following in order: --version, -V, --help, help
29pub fn check_runner_binary(bin: &str) -> BinaryStatus {
30    let fallbacks: &[&[&str]] = &[&["--version"], &["-V"], &["--help"], &["help"]];
31
32    for args in fallbacks {
33        match try_command(bin, args) {
34            Ok(output) => {
35                // Try to extract version from output
36                let version = extract_version(&output);
37                return BinaryStatus {
38                    installed: true,
39                    version,
40                    error: None,
41                };
42            }
43            Err(_) => continue,
44        }
45    }
46
47    BinaryStatus {
48        installed: false,
49        version: None,
50        error: Some(format!("binary '{}' not found or not executable", bin)),
51    }
52}
53
54fn try_command(bin: &str, args: &[&str]) -> anyhow::Result<String> {
55    let output = Command::new(bin)
56        .args(args)
57        .stdout(std::process::Stdio::piped())
58        .stderr(std::process::Stdio::piped())
59        .output()
60        .with_context(|| format!("failed to execute runner binary '{}'", bin))?;
61
62    if output.status.success() {
63        // Combine stdout and stderr for version parsing
64        let stdout = String::from_utf8_lossy(&output.stdout);
65        let stderr = String::from_utf8_lossy(&output.stderr);
66        Ok(format!("{}{}", stdout, stderr))
67    } else {
68        let stderr = String::from_utf8_lossy(&output.stderr);
69        let cmd_display = format!("{} {}", bin, args.join(" "));
70        anyhow::bail!(
71            "runner binary check failed\n  command: {}\n  exit code: {}\n  stderr: {}",
72            cmd_display.trim(),
73            output.status,
74            stderr.trim()
75        )
76    }
77}
78
79/// Extract version string from command output using common patterns.
80fn extract_version(output: &str) -> Option<String> {
81    // Look for common version patterns like "version 1.2.3" or "v1.2.3"
82    for line in output.lines().take(5) {
83        let lower = line.to_lowercase();
84        if lower.contains("version") || lower.starts_with('v') {
85            // Try to extract semver-like pattern
86            if let Some(ver) = extract_semver(line) {
87                return Some(ver);
88            }
89        }
90    }
91    // Fallback: return first non-empty line (often contains version)
92    output.lines().next().map(|s| s.trim().to_string())
93}
94
95fn extract_semver(s: &str) -> Option<String> {
96    // Simple heuristic: find digits and dots pattern
97    let chars: Vec<char> = s.chars().collect();
98    let mut start = None;
99    let mut end = None;
100
101    for (i, &c) in chars.iter().enumerate() {
102        if c.is_ascii_digit() && start.is_none() {
103            start = Some(i);
104        }
105        if let Some(s) = start
106            && !c.is_ascii_digit()
107            && c != '.'
108            && c != '-'
109            && end.is_none()
110            && i > s + 1
111        {
112            end = Some(i);
113        }
114    }
115
116    match (start, end) {
117        (Some(s), Some(e)) => Some(chars[s..e].iter().collect()),
118        // Handle version at end of string (no terminator found)
119        (Some(s), None) => Some(chars[s..].iter().collect()),
120        _ => None,
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn binary_detection_handles_missing_binary() {
130        let status = check_runner_binary("nonexistent_binary_12345");
131        assert!(!status.installed);
132        assert!(status.error.is_some());
133    }
134
135    #[test]
136    fn extract_version_finds_semver() {
137        let output = "codex version 1.2.3\nSome other info";
138        let version = extract_version(output);
139        // The function returns the first line containing "version" or starting with "v"
140        assert!(version.as_ref().unwrap().contains("1.2.3"));
141    }
142
143    #[test]
144    fn extract_version_handles_v_prefix() {
145        let output = "v2.0.0-beta\nMore info";
146        let version = extract_version(output);
147        // The function returns the first line starting with "v" or containing "version"
148        assert!(version.as_ref().unwrap().contains("2.0.0"));
149    }
150
151    #[test]
152    fn extract_semver_handles_version_at_end() {
153        // Version at end of string without terminator (bug fix verification)
154        let result = extract_semver("version 1.2.3");
155        assert_eq!(result, Some("1.2.3".to_string()));
156    }
157
158    #[test]
159    fn extract_semver_handles_standalone_version() {
160        // Just a version number with no other text (bug fix verification)
161        let result = extract_semver("1.2.3");
162        assert_eq!(result, Some("1.2.3".to_string()));
163    }
164}