use octl_core::Kind;
use crate::config::Config;
use crate::error::CliError;
use crate::run::kind_kebab;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HarnessSource {
Flag,
Env,
File,
Default,
}
impl HarnessSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
HarnessSource::Flag => "flag",
HarnessSource::Env => "env",
HarnessSource::File => "file",
HarnessSource::Default => "default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HarnessChoice {
pub name: String,
pub source: HarnessSource,
}
impl HarnessChoice {
#[must_use]
pub fn workmux_agent(&self) -> Option<&str> {
super::workmux_agent(&self.name)
}
}
pub const HARNESS_ENV: &str = "ORCHESTRATECTL_HARNESS";
pub fn resolve(kind: Kind, flag: Option<&str>) -> Result<HarnessChoice, CliError> {
if let Some(raw) = flag {
return finish(raw, HarnessSource::Flag);
}
let config = Config::load()?;
let env = std::env::var(HARNESS_ENV).ok();
resolve_with(kind, None, env.as_deref(), &config)
}
pub fn resolve_with(
kind: Kind,
flag: Option<&str>,
env: Option<&str>,
config: &Config,
) -> Result<HarnessChoice, CliError> {
if let Some(raw) = flag {
return finish(raw, HarnessSource::Flag);
}
if let Some(raw) = env {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return finish(trimmed, HarnessSource::Env);
}
}
if let Some(raw) = config.harness.per_kind.get(kind_kebab(kind)) {
return finish(raw, HarnessSource::File);
}
if let Some(raw) = config.harness.default.as_deref() {
return finish(raw, HarnessSource::File);
}
finish(super::DEFAULT_HARNESS, HarnessSource::Default)
}
fn finish(raw: &str, source: HarnessSource) -> Result<HarnessChoice, CliError> {
let name = raw.trim();
if name.is_empty() {
return Err(CliError::user(
"invalid_harness",
format!(
"empty harness name (from {}); known harnesses: {}",
source.as_str(),
super::KNOWN_HARNESSES.join(", ")
),
)
.with_expected(serde_json::json!(super::KNOWN_HARNESSES)));
}
if super::KNOWN_HARNESSES.contains(&name) {
return Ok(HarnessChoice {
name: name.to_string(),
source,
});
}
let expected = serde_json::json!(super::KNOWN_HARNESSES);
Err(CliError::user(
"invalid_harness",
format!(
"unknown harness '{name}' (from {}); known harnesses: {}",
source.as_str(),
super::KNOWN_HARNESSES.join(", ")
),
)
.with_invalid_value(name)
.with_expected(expected))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn cfg(default: Option<&str>, per_kind: &[(&str, &str)]) -> Config {
Config {
harness: crate::config::HarnessConfig {
default: default.map(str::to_string),
per_kind: per_kind
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<BTreeMap<_, _>>(),
},
}
}
#[test]
fn default_when_nothing_set() {
let got = resolve_with(Kind::Spinoff, None, None, &Config::default()).unwrap();
assert_eq!(got.name, "claude");
assert_eq!(got.source, HarnessSource::Default);
}
#[test]
fn flag_wins_over_everything() {
let c = cfg(Some("aider"), &[("spinoff", "aider")]);
let got = resolve_with(Kind::Spinoff, Some("pi"), Some("claude"), &c).unwrap();
assert_eq!(got.name, "pi");
assert_eq!(got.source, HarnessSource::Flag);
}
#[test]
fn env_wins_over_config_and_default() {
let c = cfg(Some("aider"), &[]);
let got = resolve_with(Kind::Research, None, Some("pi"), &c).unwrap();
assert_eq!(got.name, "pi");
assert_eq!(got.source, HarnessSource::Env);
}
#[test]
fn empty_env_is_ignored() {
let c = cfg(Some("pi"), &[]);
let got = resolve_with(Kind::Research, None, Some(" "), &c).unwrap();
assert_eq!(got.name, "pi");
assert_eq!(got.source, HarnessSource::File);
}
#[test]
fn per_kind_beats_config_default() {
let c = cfg(Some("claude"), &[("research", "pi")]);
let research = resolve_with(Kind::Research, None, None, &c).unwrap();
assert_eq!(research.name, "pi");
assert_eq!(research.source, HarnessSource::File);
let code = resolve_with(Kind::Code, None, None, &c).unwrap();
assert_eq!(code.name, "claude");
assert_eq!(code.source, HarnessSource::File);
}
#[test]
fn config_default_used_when_no_per_kind() {
let c = cfg(Some("pi"), &[]);
let got = resolve_with(Kind::Spinoff, None, None, &c).unwrap();
assert_eq!(got.name, "pi");
assert_eq!(got.source, HarnessSource::File);
}
#[test]
fn invalid_flag_is_rejected() {
let e = resolve_with(Kind::Spinoff, Some("gpt"), None, &Config::default()).unwrap_err();
assert_eq!(e.code, "invalid_harness");
assert_eq!(e.invalid_value.as_deref(), Some("gpt"));
assert!(e.message.contains("from flag"));
}
#[test]
fn invalid_env_names_the_layer() {
let e = resolve_with(Kind::Spinoff, None, Some("gpt"), &Config::default()).unwrap_err();
assert_eq!(e.code, "invalid_harness");
assert!(e.message.contains("from env"), "message: {}", e.message);
}
#[test]
fn invalid_config_value_names_the_layer() {
let c = cfg(Some("gpt"), &[]);
let e = resolve_with(Kind::Spinoff, None, None, &c).unwrap_err();
assert_eq!(e.code, "invalid_harness");
assert!(e.message.contains("from file"), "message: {}", e.message);
}
#[test]
fn invalid_per_kind_config_value_names_the_layer() {
let c = cfg(None, &[("spinoff", "gpt")]);
let e = resolve_with(Kind::Spinoff, None, None, &c).unwrap_err();
assert_eq!(e.code, "invalid_harness");
assert_eq!(e.invalid_value.as_deref(), Some("gpt"));
assert!(e.message.contains("from file"), "message: {}", e.message);
}
#[test]
fn empty_flag_is_a_clear_error() {
let e = resolve_with(Kind::Spinoff, Some(" "), None, &Config::default()).unwrap_err();
assert_eq!(e.code, "invalid_harness");
assert!(
e.message.contains("empty harness name"),
"message: {}",
e.message
);
}
#[test]
fn workmux_agent_maps_claude_to_none() {
let claude = HarnessChoice {
name: "claude".into(),
source: HarnessSource::Default,
};
assert_eq!(claude.workmux_agent(), None);
let pi = HarnessChoice {
name: "pi".into(),
source: HarnessSource::Flag,
};
assert_eq!(pi.workmux_agent(), Some("pi"));
}
}