ralph/commands/runner/
detection.rs1use std::process::Command;
12
13use anyhow::Context;
14
15#[derive(Debug, Clone)]
17pub struct BinaryStatus {
18 pub installed: bool,
20 pub version: Option<String>,
22 pub error: Option<String>,
24}
25
26pub 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 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 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
79fn extract_version(output: &str) -> Option<String> {
81 for line in output.lines().take(5) {
83 let lower = line.to_lowercase();
84 if lower.contains("version") || lower.starts_with('v') {
85 if let Some(ver) = extract_semver(line) {
87 return Some(ver);
88 }
89 }
90 }
91 output.lines().next().map(|s| s.trim().to_string())
93}
94
95fn extract_semver(s: &str) -> Option<String> {
96 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 (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 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 assert!(version.as_ref().unwrap().contains("2.0.0"));
149 }
150
151 #[test]
152 fn extract_semver_handles_version_at_end() {
153 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 let result = extract_semver("1.2.3");
162 assert_eq!(result, Some("1.2.3".to_string()));
163 }
164}