oneharness_core/io/
detect.rs1use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7use std::time::Duration;
8
9use wait_timeout::ChildExt;
10
11use crate::domain::harness::HarnessSpec;
12use crate::errors::OneharnessError;
13
14pub struct BinOverrides {
18 map: HashMap<String, String>,
19 config: HashMap<String, String>,
22}
23
24impl BinOverrides {
25 pub fn parse(values: &[String]) -> Result<Self, OneharnessError> {
27 let mut map = HashMap::new();
28 for value in values {
29 let (id, path) = value
30 .split_once('=')
31 .ok_or_else(|| OneharnessError::BadBinOverride(value.clone()))?;
32 if id.is_empty() || path.is_empty() {
33 return Err(OneharnessError::BadBinOverride(value.clone()));
34 }
35 map.insert(id.to_string(), path.to_string());
36 }
37 Ok(Self {
38 map,
39 config: HashMap::new(),
40 })
41 }
42
43 pub fn with_config_bins(mut self, bins: HashMap<String, String>) -> Self {
45 self.config = bins;
46 self
47 }
48
49 fn binary_for(&self, id: &str, default_bin: &str) -> String {
52 if let Some(path) = self.map.get(id) {
53 return path.clone();
54 }
55 let env_key = format!("ONEHARNESS_BIN_{}", id.to_uppercase().replace('-', "_"));
56 if let Ok(value) = std::env::var(&env_key) {
57 if !value.is_empty() {
58 return value;
59 }
60 }
61 if let Some(path) = self.config.get(id) {
62 return path.clone();
63 }
64 default_bin.to_string()
65 }
66}
67
68pub struct Resolved {
70 pub bin: String,
72 pub path: Option<PathBuf>,
74 pub available: bool,
76}
77
78pub fn resolve(spec: &HarnessSpec, overrides: &BinOverrides) -> Resolved {
80 let bin = overrides.binary_for(spec.id, spec.default_bin);
81 match which::which(&bin) {
82 Ok(path) => Resolved {
83 bin,
84 path: Some(path),
85 available: true,
86 },
87 Err(_) => Resolved {
88 bin,
89 path: None,
90 available: false,
91 },
92 }
93}
94
95pub fn probe_version(bin: &str) -> Option<String> {
98 let mut child = Command::new(bin)
99 .arg("--version")
100 .stdin(Stdio::null())
101 .stdout(Stdio::piped())
102 .stderr(Stdio::piped())
103 .spawn()
104 .ok()?;
105
106 let status = child.wait_timeout(Duration::from_secs(5)).ok()?;
107 if status.is_none() {
108 let _ = child.kill();
109 let _ = child.wait();
110 return None;
111 }
112 let output = child.wait_with_output().ok()?;
113 first_line(&output.stdout).or_else(|| first_line(&output.stderr))
114}
115
116fn first_line(bytes: &[u8]) -> Option<String> {
117 String::from_utf8_lossy(bytes)
118 .lines()
119 .map(str::trim)
120 .find(|l| !l.is_empty())
121 .map(str::to_string)
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use std::sync::Mutex;
128
129 static ENV_LOCK: Mutex<()> = Mutex::new(());
132
133 #[test]
134 fn override_takes_precedence_over_default() {
135 let ov = BinOverrides::parse(&["claude-code=/opt/claude".to_string()]).unwrap();
136 assert_eq!(ov.binary_for("claude-code", "claude"), "/opt/claude");
137 }
138
139 #[test]
140 fn default_used_when_no_override() {
141 let ov = BinOverrides::parse(&[]).unwrap();
142 assert_eq!(ov.binary_for("codex", "codex"), "codex");
143 }
144
145 #[test]
146 fn config_bin_is_used_but_loses_to_an_explicit_flag() {
147 let bins = HashMap::from([("codex".to_string(), "/cfg/codex".to_string())]);
148 let ov = BinOverrides::parse(&[]).unwrap().with_config_bins(bins);
149 assert_eq!(ov.binary_for("codex", "codex"), "/cfg/codex");
150
151 let bins = HashMap::from([("codex".to_string(), "/cfg/codex".to_string())]);
152 let ov = BinOverrides::parse(&["codex=/flag/codex".to_string()])
153 .unwrap()
154 .with_config_bins(bins);
155 assert_eq!(ov.binary_for("codex", "codex"), "/flag/codex");
156 }
157
158 #[test]
159 fn malformed_override_is_rejected() {
160 assert!(BinOverrides::parse(&["no-equals".to_string()]).is_err());
161 assert!(BinOverrides::parse(&["=/path".to_string()]).is_err());
162 assert!(BinOverrides::parse(&["id=".to_string()]).is_err());
163 }
164
165 #[test]
166 fn first_line_skips_blanks() {
167 assert_eq!(
168 first_line(b"\n \nv1.2.3\nextra"),
169 Some("v1.2.3".to_string())
170 );
171 assert_eq!(first_line(b" "), None);
172 }
173
174 #[test]
175 fn env_var_override_is_used_above_config_and_default() {
176 let _guard = ENV_LOCK.lock().unwrap();
181 let key = "ONEHARNESS_BIN_CLAUDE_CODE";
182 let prev = std::env::var(key).ok();
183
184 std::env::set_var(key, "/env/claude");
185 let bins = HashMap::from([("claude-code".to_string(), "/cfg/claude".to_string())]);
186 let ov = BinOverrides::parse(&[]).unwrap().with_config_bins(bins);
187 assert_eq!(ov.binary_for("claude-code", "claude"), "/env/claude");
188
189 std::env::set_var(key, "");
192 assert_eq!(ov.binary_for("claude-code", "claude"), "/cfg/claude");
193
194 match prev {
195 Some(v) => std::env::set_var(key, v),
196 None => std::env::remove_var(key),
197 }
198 }
199
200 #[test]
201 fn explicit_flag_beats_env_var() {
202 let _guard = ENV_LOCK.lock().unwrap();
204 let key = "ONEHARNESS_BIN_CODEX";
205 let prev = std::env::var(key).ok();
206 std::env::set_var(key, "/env/codex");
207
208 let ov = BinOverrides::parse(&["codex=/flag/codex".to_string()]).unwrap();
209 assert_eq!(ov.binary_for("codex", "codex"), "/flag/codex");
210
211 match prev {
212 Some(v) => std::env::set_var(key, v),
213 None => std::env::remove_var(key),
214 }
215 }
216}