Skip to main content

oneharness_core/io/
detect.rs

1//! Resolving and probing harness binaries. This is an I/O boundary: it reads the
2//! environment, searches PATH, and may spawn `<bin> --version`.
3
4use 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
14/// Per-harness binary overrides, resolved from `--bin ID=PATH`, then the
15/// `ONEHARNESS_BIN_<ID>` environment variable, then a config-file
16/// `[harness.<id>] bin`, falling back to the spec default.
17pub struct BinOverrides {
18    map: HashMap<String, String>,
19    /// Config-file bins: the lowest-precedence override layer, since an
20    /// explicit flag or env var is more deliberate than a persisted default.
21    config: HashMap<String, String>,
22}
23
24impl BinOverrides {
25    /// Parse `--bin ID=PATH` values. Errors on a value missing its `=`.
26    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    /// Attach the config-file bins (`[harness.<id>] bin = "..."`) as fallbacks.
44    pub fn with_config_bins(mut self, bins: HashMap<String, String>) -> Self {
45        self.config = bins;
46        self
47    }
48
49    /// The binary to invoke for `id`: explicit override, then env var, then the
50    /// config-file bin, then default.
51    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
68/// The result of locating a harness binary on the current system.
69pub struct Resolved {
70    /// The binary string oneharness will invoke (name or path).
71    pub bin: String,
72    /// The absolute path it resolved to, if found.
73    pub path: Option<PathBuf>,
74    /// Whether the binary was found and is executable.
75    pub available: bool,
76}
77
78/// Resolve the binary for a harness without running it.
79pub 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
95/// Best-effort `<bin> --version`, returning the first non-empty output line.
96/// Never fails loudly: a probe that errors or times out simply yields `None`.
97pub 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    // Serializes the env-mutating tests: the process environment is global, so
130    // concurrent set/remove from two tests in the same binary would race.
131    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        // With no `--bin` flag, the per-harness `ONEHARNESS_BIN_<ID>` env var
177        // (id upper-cased, `-`→`_`) selects the binary, ahead of any config-file
178        // bin and the spec default. A unique key keeps this independent of the
179        // ambient environment.
180        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        // An empty env value is ignored — the next layer (config, then default)
190        // wins instead.
191        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        // A `--bin` flag takes precedence over the env var for the same id.
203        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}