ralph/commands/runner/
detection.rs1use anyhow::Context;
12
13use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
14use std::process::Command;
15
16#[derive(Debug, Clone)]
18pub struct BinaryStatus {
19 pub installed: bool,
21 pub version: Option<String>,
23 pub error: Option<String>,
25}
26
27pub 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 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 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
86fn extract_version(output: &str) -> Option<String> {
88 for line in output.lines().take(5) {
90 let lower = line.to_lowercase();
91 if lower.contains("version") || lower.starts_with('v') {
92 if let Some(ver) = extract_semver(line) {
94 return Some(ver);
95 }
96 }
97 }
98 output.lines().next().map(|s| s.trim().to_string())
100}
101
102fn extract_semver(s: &str) -> Option<String> {
103 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 (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 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 assert!(version.as_ref().unwrap().contains("2.0.0"));
156 }
157
158 #[test]
159 fn extract_semver_handles_version_at_end() {
160 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 let result = extract_semver("1.2.3");
169 assert_eq!(result, Some("1.2.3".to_string()));
170 }
171}