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