use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;
use crate::domain::harness::HarnessSpec;
use crate::errors::OneharnessError;
pub struct BinOverrides {
map: HashMap<String, String>,
config: HashMap<String, String>,
}
impl BinOverrides {
pub fn parse(values: &[String]) -> Result<Self, OneharnessError> {
let mut map = HashMap::new();
for value in values {
let (id, path) = value
.split_once('=')
.ok_or_else(|| OneharnessError::BadBinOverride(value.clone()))?;
if id.is_empty() || path.is_empty() {
return Err(OneharnessError::BadBinOverride(value.clone()));
}
map.insert(id.to_string(), path.to_string());
}
Ok(Self {
map,
config: HashMap::new(),
})
}
pub fn with_config_bins(mut self, bins: HashMap<String, String>) -> Self {
self.config = bins;
self
}
fn binary_for(&self, id: &str, default_bin: &str) -> String {
if let Some(path) = self.map.get(id) {
return path.clone();
}
let env_key = format!("ONEHARNESS_BIN_{}", id.to_uppercase().replace('-', "_"));
if let Ok(value) = std::env::var(&env_key) {
if !value.is_empty() {
return value;
}
}
if let Some(path) = self.config.get(id) {
return path.clone();
}
default_bin.to_string()
}
}
pub struct Resolved {
pub bin: String,
pub path: Option<PathBuf>,
pub available: bool,
}
pub fn resolve(spec: &HarnessSpec, overrides: &BinOverrides) -> Resolved {
let bin = overrides.binary_for(spec.id, spec.default_bin);
match which::which(&bin) {
Ok(path) => Resolved {
bin,
path: Some(path),
available: true,
},
Err(_) => Resolved {
bin,
path: None,
available: false,
},
}
}
pub fn probe_version(bin: &str) -> Option<String> {
let mut child = Command::new(bin)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let status = child.wait_timeout(Duration::from_secs(5)).ok()?;
if status.is_none() {
let _ = child.kill();
let _ = child.wait();
return None;
}
let output = child.wait_with_output().ok()?;
first_line(&output.stdout).or_else(|| first_line(&output.stderr))
}
fn first_line(bytes: &[u8]) -> Option<String> {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn override_takes_precedence_over_default() {
let ov = BinOverrides::parse(&["claude-code=/opt/claude".to_string()]).unwrap();
assert_eq!(ov.binary_for("claude-code", "claude"), "/opt/claude");
}
#[test]
fn default_used_when_no_override() {
let ov = BinOverrides::parse(&[]).unwrap();
assert_eq!(ov.binary_for("codex", "codex"), "codex");
}
#[test]
fn config_bin_is_used_but_loses_to_an_explicit_flag() {
let bins = HashMap::from([("codex".to_string(), "/cfg/codex".to_string())]);
let ov = BinOverrides::parse(&[]).unwrap().with_config_bins(bins);
assert_eq!(ov.binary_for("codex", "codex"), "/cfg/codex");
let bins = HashMap::from([("codex".to_string(), "/cfg/codex".to_string())]);
let ov = BinOverrides::parse(&["codex=/flag/codex".to_string()])
.unwrap()
.with_config_bins(bins);
assert_eq!(ov.binary_for("codex", "codex"), "/flag/codex");
}
#[test]
fn malformed_override_is_rejected() {
assert!(BinOverrides::parse(&["no-equals".to_string()]).is_err());
assert!(BinOverrides::parse(&["=/path".to_string()]).is_err());
assert!(BinOverrides::parse(&["id=".to_string()]).is_err());
}
#[test]
fn first_line_skips_blanks() {
assert_eq!(
first_line(b"\n \nv1.2.3\nextra"),
Some("v1.2.3".to_string())
);
assert_eq!(first_line(b" "), None);
}
#[test]
fn env_var_override_is_used_above_config_and_default() {
let _guard = ENV_LOCK.lock().unwrap();
let key = "ONEHARNESS_BIN_CLAUDE_CODE";
let prev = std::env::var(key).ok();
std::env::set_var(key, "/env/claude");
let bins = HashMap::from([("claude-code".to_string(), "/cfg/claude".to_string())]);
let ov = BinOverrides::parse(&[]).unwrap().with_config_bins(bins);
assert_eq!(ov.binary_for("claude-code", "claude"), "/env/claude");
std::env::set_var(key, "");
assert_eq!(ov.binary_for("claude-code", "claude"), "/cfg/claude");
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn explicit_flag_beats_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let key = "ONEHARNESS_BIN_CODEX";
let prev = std::env::var(key).ok();
std::env::set_var(key, "/env/codex");
let ov = BinOverrides::parse(&["codex=/flag/codex".to_string()]).unwrap();
assert_eq!(ov.binary_for("codex", "codex"), "/flag/codex");
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}