use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::domain::harness;
use crate::domain::hooks::HookSpec;
use crate::domain::mode::PermissionMode;
use crate::domain::report::OutputFormat;
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub all: Option<bool>,
pub harnesses: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub model: Option<String>,
pub system: Option<String>,
pub bypass: Option<bool>,
pub mode: Option<PermissionMode>,
pub timeout: Option<u64>,
pub output_format: Option<OutputFormat>,
pub schema_file: Option<String>,
pub schema_max_retries: Option<u32>,
pub max_parallel: Option<usize>,
pub require_available: Option<bool>,
pub allowed_tools: Option<Vec<String>>,
pub denied_tools: Option<Vec<String>>,
pub hooks: Option<Vec<HookEntry>>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub harness: BTreeMap<String, HarnessConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HarnessConfig {
pub model: Option<String>,
pub bin: Option<String>,
pub args: Option<Vec<String>>,
pub allowed_tools: Option<Vec<String>>,
pub denied_tools: Option<Vec<String>>,
pub hooks: Option<toml::Value>,
pub settings: Option<toml::Value>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct HookEntry {
pub command: String,
pub matcher: Option<String>,
pub timeout: Option<u64>,
pub plugin_name: Option<String>,
pub harnesses: Option<Vec<String>>,
}
pub fn parse(text: &str) -> Result<FileConfig, String> {
let config: FileConfig = toml::from_str(text).map_err(|e| e.to_string())?;
validate(&config)?;
Ok(config)
}
fn validate(config: &FileConfig) -> Result<(), String> {
if config.all == Some(true) && config.harnesses.is_some() {
return Err("`all = true` and `harnesses` are mutually exclusive".to_string());
}
let hook_harnesses = config
.hooks
.iter()
.flatten()
.flat_map(|h| h.harnesses.iter().flatten());
let named = config
.harnesses
.iter()
.flatten()
.chain(config.exclude.iter().flatten())
.chain(hook_harnesses)
.map(String::as_str)
.chain(config.harness.keys().map(String::as_str));
for id in named {
if harness::by_id(id).is_none() {
return Err(format!(
"unknown harness id `{id}`. valid ids: {}",
harness::valid_ids()
));
}
}
for entry in config.hooks.iter().flatten() {
if entry.command.trim().is_empty() {
return Err("a `[[hooks]]` entry needs a non-empty `command`".to_string());
}
for id in entry.harnesses.iter().flatten() {
if harness::by_id(id)
.expect("hook harness id validated")
.hooks
.is_none()
{
return Err(format!(
"`[[hooks]]` targets harness `{id}`, which oneharness cannot install a hook into"
));
}
}
}
for (id, h) in &config.harness {
let spec = harness::by_id(id).expect("ids validated above");
let sync = spec.sync.as_ref();
let unsupported = [
(h.allowed_tools.is_some() && sync.and_then(|s| s.allow_path).is_none())
.then_some("allowed_tools"),
(h.denied_tools.is_some() && sync.and_then(|s| s.deny_path).is_none())
.then_some("denied_tools"),
(h.hooks.is_some() && sync.and_then(|s| s.hooks_path).is_none()).then_some("hooks"),
(h.settings.is_some() && sync.is_none()).then_some("settings"),
];
if let Some(setting) = unsupported.into_iter().flatten().next() {
return Err(format!(
"`oneharness sync` cannot deliver `{setting}` for harness `{id}`: its \
config file has no mapping for it. Use `[harness.{id}.settings]` if the \
harness has a config file (see `sync_file` in `oneharness list`), or \
`[harness.{id}] args` for flag-based harnesses"
));
}
for (name, value) in [("hooks", &h.hooks), ("settings", &h.settings)] {
if value.as_ref().is_some_and(|v| !v.is_table()) {
return Err(format!(
"`{name}` for harness `{id}` must be a table (e.g. `[harness.{id}.{name}]`)"
));
}
}
}
for key in config
.env
.keys()
.chain(config.harness.values().flat_map(|h| h.env.keys()))
{
if key.is_empty() {
return Err("environment variable names must be non-empty".to_string());
}
}
Ok(())
}
pub const ENV_SOURCE: &str = "environment";
pub fn from_env(get: impl Fn(&str) -> Option<String>) -> Result<Option<FileConfig>, String> {
let read = |name: &str| get(name).filter(|v| !v.is_empty());
let config = FileConfig {
all: env_bool(&read, "ONEHARNESS_ALL")?,
harnesses: env_list(&read, "ONEHARNESS_HARNESSES"),
exclude: env_list(&read, "ONEHARNESS_EXCLUDE"),
model: read("ONEHARNESS_MODEL"),
system: read("ONEHARNESS_SYSTEM"),
bypass: env_bool(&read, "ONEHARNESS_BYPASS")?,
mode: env_mode(&read)?,
timeout: env_num(&read, "ONEHARNESS_TIMEOUT", "a non-negative integer")?,
output_format: env_output_format(&read)?,
schema_file: read("ONEHARNESS_SCHEMA_FILE"),
schema_max_retries: env_num(
&read,
"ONEHARNESS_SCHEMA_MAX_RETRIES",
"a non-negative integer",
)?,
max_parallel: env_num(&read, "ONEHARNESS_MAX_PARALLEL", "a non-negative integer")?,
require_available: env_bool(&read, "ONEHARNESS_REQUIRE_AVAILABLE")?,
..FileConfig::default()
};
if config == FileConfig::default() {
return Ok(None);
}
validate(&config)?;
Ok(Some(config))
}
fn env_bool<F: Fn(&str) -> Option<String>>(read: &F, name: &str) -> Result<Option<bool>, String> {
match read(name) {
None => Ok(None),
Some(v) => match v.to_ascii_lowercase().as_str() {
"true" | "1" => Ok(Some(true)),
"false" | "0" => Ok(Some(false)),
_ => Err(format!("`{name}` must be `true` or `false`, got `{v}`")),
},
}
}
fn env_num<T, F>(read: &F, name: &str, what: &str) -> Result<Option<T>, String>
where
T: std::str::FromStr,
F: Fn(&str) -> Option<String>,
{
match read(name) {
None => Ok(None),
Some(v) => v
.parse::<T>()
.map(Some)
.map_err(|_| format!("`{name}` must be {what}, got `{v}`")),
}
}
fn env_list<F: Fn(&str) -> Option<String>>(read: &F, name: &str) -> Option<Vec<String>> {
read(name).map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
}
fn env_mode<F: Fn(&str) -> Option<String>>(read: &F) -> Result<Option<PermissionMode>, String> {
let name = "ONEHARNESS_MODE";
match read(name) {
None => Ok(None),
Some(v) => PermissionMode::parse(&v)
.map(Some)
.map_err(|e| format!("`{name}` {e}")),
}
}
fn env_output_format<F: Fn(&str) -> Option<String>>(
read: &F,
) -> Result<Option<OutputFormat>, String> {
let name = "ONEHARNESS_OUTPUT_FORMAT";
match read(name) {
None => Ok(None),
Some(v) => match v.as_str() {
"text" => Ok(Some(OutputFormat::Text)),
"json" => Ok(Some(OutputFormat::Json)),
"stream-json" => Ok(Some(OutputFormat::StreamJson)),
_ => Err(format!(
"`{name}` must be one of `text`, `json`, `stream-json`, got `{v}`"
)),
},
}
}
pub fn merge(base: FileConfig, over: FileConfig) -> FileConfig {
let (all, harnesses) = if over.all.is_some() || over.harnesses.is_some() {
(over.all, over.harnesses)
} else {
(base.all, base.harnesses)
};
let mut env = base.env;
env.extend(over.env);
let mut harness = base.harness;
for (id, o) in over.harness {
let entry = harness.entry(id).or_default();
let mut merged_env = std::mem::take(&mut entry.env);
merged_env.extend(o.env);
*entry = HarnessConfig {
model: o.model.or(entry.model.take()),
bin: o.bin.or(entry.bin.take()),
args: o.args.or(entry.args.take()),
allowed_tools: o.allowed_tools.or(entry.allowed_tools.take()),
denied_tools: o.denied_tools.or(entry.denied_tools.take()),
hooks: o.hooks.or(entry.hooks.take()),
settings: o.settings.or(entry.settings.take()),
env: merged_env,
};
}
FileConfig {
all,
harnesses,
exclude: over.exclude.or(base.exclude),
model: over.model.or(base.model),
system: over.system.or(base.system),
bypass: over.bypass.or(base.bypass),
mode: over.mode.or(base.mode),
timeout: over.timeout.or(base.timeout),
output_format: over.output_format.or(base.output_format),
schema_file: over.schema_file.or(base.schema_file),
schema_max_retries: over.schema_max_retries.or(base.schema_max_retries),
max_parallel: over.max_parallel.or(base.max_parallel),
require_available: over.require_available.or(base.require_available),
allowed_tools: over.allowed_tools.or(base.allowed_tools),
denied_tools: over.denied_tools.or(base.denied_tools),
hooks: over.hooks.or(base.hooks),
env,
harness,
}
}
impl FileConfig {
pub fn model_for(&self, id: &str) -> Option<&str> {
self.harness
.get(id)
.and_then(|h| h.model.as_deref())
.or(self.model.as_deref())
}
pub fn bin_for(&self, id: &str) -> Option<&str> {
self.harness.get(id).and_then(|h| h.bin.as_deref())
}
pub fn args_for(&self, id: &str) -> &[String] {
self.harness
.get(id)
.and_then(|h| h.args.as_deref())
.unwrap_or(&[])
}
pub fn allowed_tools_for(&self, id: &str) -> &[String] {
self.harness
.get(id)
.and_then(|h| h.allowed_tools.as_deref())
.or(self.allowed_tools.as_deref())
.unwrap_or(&[])
}
pub fn denied_tools_for(&self, id: &str) -> &[String] {
self.harness
.get(id)
.and_then(|h| h.denied_tools.as_deref())
.or(self.denied_tools.as_deref())
.unwrap_or(&[])
}
pub fn hooks_for(&self, id: &str) -> Option<&toml::Value> {
self.harness.get(id).and_then(|h| h.hooks.as_ref())
}
pub fn hook_specs_for(&self, id: &str) -> Vec<HookSpec> {
self.hooks
.iter()
.flatten()
.filter(|entry| match &entry.harnesses {
Some(ids) => ids.iter().any(|h| h == id),
None => true,
})
.map(|entry| HookSpec {
command: entry.command.replace("{harness}", id),
matcher: entry.matcher.clone(),
timeout: entry.timeout,
plugin_name: entry.plugin_name.clone(),
description: None,
})
.collect()
}
pub fn settings_for(&self, id: &str) -> Option<&toml::Value> {
self.harness.get(id).and_then(|h| h.settings.as_ref())
}
pub fn env_for(&self, id: &str) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = self
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if let Some(h) = self.harness.get(id) {
env.extend(h.env.iter().map(|(k, v)| (k.clone(), v.clone())));
}
env
}
}
pub const DEFAULT_SOURCE: &str = "default";
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Field<T> {
pub value: Option<T>,
pub source: Option<String>,
}
impl<T> Field<T> {
fn unset() -> Self {
Self {
value: None,
source: None,
}
}
fn default_value(value: T) -> Self {
Self {
value: Some(value),
source: Some(DEFAULT_SOURCE.to_string()),
}
}
fn or_default(self, value: T) -> Self {
if self.value.is_some() {
self
} else {
Self::default_value(value)
}
}
}
#[derive(Debug, Serialize)]
pub struct ConfigReport {
pub schema_version: &'static str,
pub config_files: Vec<String>,
pub all: Field<bool>,
pub harnesses: Field<Vec<String>>,
pub exclude: Field<Vec<String>>,
pub model: Field<String>,
pub system: Field<String>,
pub bypass: Field<bool>,
pub mode: Field<PermissionMode>,
pub timeout: Field<u64>,
pub output_format: Field<OutputFormat>,
pub schema_file: Field<String>,
pub schema_max_retries: Field<u32>,
pub max_parallel: Field<usize>,
pub require_available: Field<bool>,
pub allowed_tools: Field<Vec<String>>,
pub denied_tools: Field<Vec<String>>,
pub hooks: Field<Vec<HookEntry>>,
pub env: BTreeMap<String, Field<String>>,
pub harness: BTreeMap<String, HarnessReport>,
}
#[derive(Debug, Serialize)]
pub struct HarnessReport {
pub model: Field<String>,
pub bin: Field<String>,
pub args: Field<Vec<String>>,
pub allowed_tools: Field<Vec<String>>,
pub denied_tools: Field<Vec<String>>,
pub hooks: Field<toml::Value>,
pub settings: Field<toml::Value>,
pub env: BTreeMap<String, Field<String>>,
}
fn pick<T: Clone>(
layers: &[(String, FileConfig)],
get: impl Fn(&FileConfig) -> Option<T>,
) -> Field<T> {
let mut field = Field::unset();
for (path, config) in layers {
if let Some(value) = get(config) {
field = Field {
value: Some(value),
source: Some(path.clone()),
};
}
}
field
}
pub fn explain(layers: &[(String, FileConfig)]) -> ConfigReport {
let (all, harnesses) = layers
.iter()
.rev()
.find(|(_, c)| c.all.is_some() || c.harnesses.is_some())
.map(|(path, c)| {
(
Field {
source: c.all.is_some().then(|| path.clone()),
value: c.all,
},
Field {
source: c.harnesses.is_some().then(|| path.clone()),
value: c.harnesses.clone(),
},
)
})
.unwrap_or((Field::unset(), Field::unset()));
let mut env: BTreeMap<String, Field<String>> = BTreeMap::new();
for (path, config) in layers {
for (key, value) in &config.env {
env.insert(
key.clone(),
Field {
value: Some(value.clone()),
source: Some(path.clone()),
},
);
}
}
let mut harness: BTreeMap<String, HarnessReport> = BTreeMap::new();
let ids: std::collections::BTreeSet<&String> =
layers.iter().flat_map(|(_, c)| c.harness.keys()).collect();
for id in ids {
let section = |c: &FileConfig| c.harness.get(id).cloned();
let mut h_env: BTreeMap<String, Field<String>> = BTreeMap::new();
for (path, config) in layers {
if let Some(h) = config.harness.get(id) {
for (key, value) in &h.env {
h_env.insert(
key.clone(),
Field {
value: Some(value.clone()),
source: Some(path.clone()),
},
);
}
}
}
harness.insert(
id.clone(),
HarnessReport {
model: pick(layers, |c| section(c).and_then(|h| h.model)),
bin: pick(layers, |c| section(c).and_then(|h| h.bin)),
args: pick(layers, |c| section(c).and_then(|h| h.args)),
allowed_tools: pick(layers, |c| section(c).and_then(|h| h.allowed_tools)),
denied_tools: pick(layers, |c| section(c).and_then(|h| h.denied_tools)),
hooks: pick(layers, |c| section(c).and_then(|h| h.hooks)),
settings: pick(layers, |c| section(c).and_then(|h| h.settings)),
env: h_env,
},
);
}
ConfigReport {
schema_version: crate::domain::report::SCHEMA_VERSION,
config_files: layers.iter().map(|(path, _)| path.clone()).collect(),
all,
harnesses,
exclude: pick(layers, |c| c.exclude.clone()),
model: pick(layers, |c| c.model.clone()),
system: pick(layers, |c| c.system.clone()),
bypass: pick(layers, |c| c.bypass).or_default(false),
mode: pick(layers, |c| c.mode),
timeout: pick(layers, |c| c.timeout).or_default(120),
output_format: pick(layers, |c| c.output_format),
schema_file: pick(layers, |c| c.schema_file.clone()),
schema_max_retries: pick(layers, |c| c.schema_max_retries),
max_parallel: pick(layers, |c| c.max_parallel),
require_available: pick(layers, |c| c.require_available).or_default(false),
allowed_tools: pick(layers, |c| c.allowed_tools.clone()),
denied_tools: pick(layers, |c| c.denied_tools.clone()),
hooks: pick(layers, |c| c.hooks.clone()),
env,
harness,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parsed(text: &str) -> FileConfig {
parse(text).expect("config should parse")
}
#[test]
fn empty_config_is_all_defaults() {
let c = parsed("");
assert_eq!(c, FileConfig::default());
}
#[test]
fn full_config_round_trips() {
let c = parsed(
r#"
harnesses = ["claude-code", "codex"]
exclude = ["cursor"]
model = "haiku"
system = "be terse"
bypass = false
mode = "plan"
timeout = 90
output_format = "stream-json"
schema_file = "schema.json"
schema_max_retries = 4
max_parallel = 2
require_available = true
[env]
FOO = "bar"
[harness.claude-code]
model = "sonnet"
bin = "/opt/claude"
args = ["--max-turns", "6"]
env = { BAZ = "qux" }
"#,
);
assert_eq!(c.harnesses.as_deref().unwrap(), ["claude-code", "codex"]);
assert_eq!(c.exclude.as_deref().unwrap(), ["cursor"]);
assert_eq!(c.model.as_deref(), Some("haiku"));
assert_eq!(c.bypass, Some(false));
assert_eq!(c.mode, Some(PermissionMode::Plan));
assert_eq!(c.timeout, Some(90));
assert_eq!(c.output_format, Some(OutputFormat::StreamJson));
assert_eq!(c.schema_file.as_deref(), Some("schema.json"));
assert_eq!(c.schema_max_retries, Some(4));
assert_eq!(c.max_parallel, Some(2));
assert_eq!(c.require_available, Some(true));
assert_eq!(c.env["FOO"], "bar");
assert_eq!(c.model_for("claude-code"), Some("sonnet"));
assert_eq!(c.model_for("codex"), Some("haiku"));
assert_eq!(c.bin_for("claude-code"), Some("/opt/claude"));
assert_eq!(c.args_for("claude-code"), ["--max-turns", "6"]);
assert_eq!(c.args_for("codex"), Vec::<String>::new().as_slice());
}
#[test]
fn normalized_hooks_filter_and_substitute_per_harness() {
let c = parsed(
r#"
[[hooks]]
command = "mygate hook {harness}"
matcher = "Bash"
timeout = 10
[[hooks]]
command = "extra hook"
harnesses = ["codex"]
"#,
);
let claude = c.hook_specs_for("claude-code");
assert_eq!(claude.len(), 1);
assert_eq!(claude[0].command, "mygate hook claude-code");
assert_eq!(claude[0].matcher.as_deref(), Some("Bash"));
assert_eq!(claude[0].timeout, Some(10));
let codex = c.hook_specs_for("codex");
assert_eq!(codex.len(), 2);
assert_eq!(codex[0].command, "mygate hook codex");
assert_eq!(codex[1].command, "extra hook");
}
#[test]
fn hooks_merge_replaces_the_list_per_layer() {
let base = parsed("[[hooks]]\ncommand = \"user gate\"");
let over = parsed("[[hooks]]\ncommand = \"project gate\"");
let merged = merge(base, over);
let specs = merged.hook_specs_for("claude-code");
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].command, "project gate");
}
#[test]
fn an_empty_hook_command_is_rejected() {
let err = parse("[[hooks]]\ncommand = \" \"").unwrap_err();
assert!(err.contains("non-empty `command`"), "{err}");
}
#[test]
fn a_hook_targeting_an_unknown_harness_is_rejected() {
let err = parse("[[hooks]]\ncommand = \"x\"\nharnesses = [\"bogus\"]").unwrap_err();
assert!(err.contains("bogus"), "{err}");
}
#[test]
fn unknown_top_level_field_is_rejected() {
let err = parse("modle = \"typo\"").unwrap_err();
assert!(err.contains("modle"), "{err}");
}
#[test]
fn unknown_per_harness_field_is_rejected() {
assert!(parse("[harness.claude-code]\nmodle = \"x\"").is_err());
}
#[test]
fn unknown_harness_id_is_rejected_everywhere() {
for text in [
"harnesses = [\"bogus\"]",
"exclude = [\"bogus\"]",
"[harness.bogus]\nmodel = \"x\"",
] {
let err = parse(text).unwrap_err();
assert!(err.contains("bogus"), "{text} -> {err}");
}
}
#[test]
fn all_and_harnesses_together_are_rejected() {
let err = parse("all = true\nharnesses = [\"codex\"]").unwrap_err();
assert!(err.contains("mutually exclusive"), "{err}");
}
#[test]
fn allow_deny_and_hooks_parse_at_both_levels() {
let c = parsed(
r#"
allowed_tools = ["Bash(git log:*)"]
denied_tools = ["Bash(rm:*)"]
[harness.cursor]
allowed_tools = ["Shell(git)"]
[harness.claude-code.hooks]
PreToolUse = [{ matcher = "Bash", hooks = [{ type = "command", command = "./check.sh" }] }]
"#,
);
assert_eq!(c.allowed_tools_for("cursor"), ["Shell(git)"]);
assert_eq!(c.allowed_tools_for("claude-code"), ["Bash(git log:*)"]);
assert_eq!(c.denied_tools_for("claude-code"), ["Bash(rm:*)"]);
assert!(c.hooks_for("claude-code").is_some());
assert!(c.hooks_for("cursor").is_none());
}
#[test]
fn sync_settings_without_a_file_mapping_are_rejected_at_parse() {
for (text, what) in [
("[harness.codex]\nallowed_tools = [\"x\"]", "allowed_tools"),
(
"[harness.copilot]\nallowed_tools = [\"x\"]",
"allowed_tools",
),
(
"[harness.opencode]\nallowed_tools = [\"x\"]",
"allowed_tools",
),
("[harness.crush]\nhooks = {}", "hooks"),
("[harness.cursor.hooks]\nbeforeShellExecution = []", "hooks"),
(
"[harness.goose.settings]\nGOOSE_MODE = \"auto\"",
"settings",
),
] {
let err = parse(text).unwrap_err();
assert!(err.contains(what), "{text} -> {err}");
assert!(err.contains("cannot deliver"), "{text} -> {err}");
}
for text in [
"[harness.qwen]\ndenied_tools = [\"Shell\"]",
"[harness.crush]\ndenied_tools = [\"bash\"]",
"[harness.cursor]\nallowed_tools = [\"Shell(ls)\"]",
"[harness.claude-code.hooks]\nPreToolUse = []",
"[harness.opencode.settings.permission]\nedit = \"deny\"",
] {
assert!(parse(text).is_ok(), "{text} should parse");
}
}
#[test]
fn non_table_hooks_and_settings_are_rejected_at_parse() {
for text in [
"[harness.claude-code]\nhooks = \"oops\"",
"[harness.claude-code]\nsettings = 3",
"[harness.opencode]\nsettings = [\"list\"]",
] {
let err = parse(text).unwrap_err();
assert!(err.contains("must be a table"), "{text} -> {err}");
}
}
#[test]
fn merge_replaces_settings_tables_per_layer() {
let base = parsed("[harness.opencode.settings.permission]\nedit = \"deny\"");
let over = parsed("[harness.opencode.settings.permission.bash]\n\"git *\" = \"allow\"");
let merged = merge(base, over);
let settings = merged.settings_for("opencode").unwrap();
assert!(settings.get("permission").unwrap().get("bash").is_some());
assert!(
settings.get("permission").unwrap().get("edit").is_none(),
"tables replace across layers, not merge"
);
}
#[test]
fn merge_replaces_rule_lists_per_field() {
let base = parsed("allowed_tools = [\"user-rule\"]\ndenied_tools = [\"user-deny\"]");
let over = parsed("allowed_tools = [\"project-rule\"]");
let merged = merge(base, over);
assert_eq!(merged.allowed_tools.as_deref().unwrap(), ["project-rule"]);
assert_eq!(merged.denied_tools.as_deref().unwrap(), ["user-deny"]);
let base = parsed("[harness.claude-code.hooks]\nPreToolUse = []");
let over = parsed("[harness.claude-code.hooks]\nPostToolUse = []");
let merged = merge(base, over);
let hooks = merged.hooks_for("claude-code").unwrap();
assert!(hooks.get("PostToolUse").is_some());
assert!(
hooks.get("PreToolUse").is_none(),
"tables replace, not merge"
);
}
#[test]
fn explain_covers_rules_and_hooks() {
let report = explain(&layers(
"allowed_tools = [\"user-rule\"]",
"[harness.claude-code]\nallowed_tools = [\"claude-rule\"]\n[harness.claude-code.hooks]\nPreToolUse = []",
));
assert_eq!(
report.allowed_tools.value.as_deref().unwrap(),
["user-rule"]
);
assert_eq!(report.allowed_tools.source.as_deref(), Some("/user.toml"));
let claude = &report.harness["claude-code"];
assert_eq!(
claude.allowed_tools.source.as_deref(),
Some("/project.toml")
);
assert_eq!(claude.hooks.source.as_deref(), Some("/project.toml"));
}
#[test]
fn merge_prefers_over_per_field_and_falls_through() {
let base = parsed("model = \"user\"\nsystem = \"keep me\"\ntimeout = 30");
let over = parsed("model = \"project\"");
let merged = merge(base, over);
assert_eq!(merged.model.as_deref(), Some("project"));
assert_eq!(merged.system.as_deref(), Some("keep me"));
assert_eq!(merged.timeout, Some(30));
}
#[test]
fn merge_moves_selection_as_a_unit() {
let base = parsed("all = true");
let over = parsed("harnesses = [\"codex\"]");
let merged = merge(base, over);
assert_eq!(merged.all, None);
assert_eq!(merged.harnesses.as_deref().unwrap(), ["codex"]);
let merged = merge(parsed("harnesses = [\"codex\"]"), parsed("all = true"));
assert_eq!(merged.all, Some(true));
assert_eq!(merged.harnesses, None);
}
#[test]
fn schema_fields_layer_and_explain_per_field() {
let merged = merge(
parsed("schema_file = \"user.json\"\nschema_max_retries = 1"),
parsed("schema_max_retries = 5"),
);
assert_eq!(merged.schema_file.as_deref(), Some("user.json"));
assert_eq!(merged.schema_max_retries, Some(5));
let report = explain(&layers(
"schema_file = \"user.json\"",
"schema_file = \"proj.json\"\nschema_max_retries = 3",
));
assert_eq!(report.schema_file.value.as_deref(), Some("proj.json"));
assert_eq!(report.schema_file.source.as_deref(), Some("/project.toml"));
assert_eq!(report.schema_max_retries.value, Some(3));
let report = explain(&[]);
assert_eq!(report.schema_max_retries, Field::unset());
assert_eq!(report.schema_file, Field::unset());
}
#[test]
fn merge_env_is_keywise_with_over_winning() {
let base = parsed("[env]\nA = \"base\"\nB = \"base\"");
let over = parsed("[env]\nB = \"over\"\nC = \"over\"");
let merged = merge(base, over);
assert_eq!(merged.env["A"], "base");
assert_eq!(merged.env["B"], "over");
assert_eq!(merged.env["C"], "over");
}
#[test]
fn merge_harness_tables_per_field() {
let base = parsed("[harness.claude-code]\nmodel = \"user\"\nbin = \"/usr/bin/claude\"");
let over = parsed("[harness.claude-code]\nmodel = \"project\"");
let merged = merge(base, over);
assert_eq!(merged.model_for("claude-code"), Some("project"));
assert_eq!(merged.bin_for("claude-code"), Some("/usr/bin/claude"));
}
fn layers(user: &str, project: &str) -> Vec<(String, FileConfig)> {
vec![
("/user.toml".to_string(), parsed(user)),
("/project.toml".to_string(), parsed(project)),
]
}
#[test]
fn explain_with_no_layers_shows_only_built_in_defaults() {
let report = explain(&[]);
assert!(report.config_files.is_empty());
assert_eq!(report.model, Field::unset());
assert_eq!(report.bypass, Field::default_value(false));
assert_eq!(report.timeout, Field::default_value(120));
assert_eq!(report.require_available, Field::default_value(false));
assert!(report.env.is_empty());
assert!(report.harness.is_empty());
}
#[test]
fn explain_attributes_each_field_to_its_winning_layer() {
let report = explain(&layers(
"model = \"user\"\ntimeout = 30",
"model = \"project\"",
));
assert_eq!(report.config_files, ["/user.toml", "/project.toml"]);
assert_eq!(report.model.value.as_deref(), Some("project"));
assert_eq!(report.model.source.as_deref(), Some("/project.toml"));
assert_eq!(report.timeout.value, Some(30));
assert_eq!(report.timeout.source.as_deref(), Some("/user.toml"));
assert_eq!(report.bypass.source.as_deref(), Some(DEFAULT_SOURCE));
}
#[test]
fn explain_moves_selection_as_a_unit_like_merge() {
let report = explain(&layers("all = true", "harnesses = [\"codex\"]"));
assert_eq!(report.all, Field::unset());
assert_eq!(report.harnesses.value.as_deref().unwrap(), ["codex"]);
assert_eq!(report.harnesses.source.as_deref(), Some("/project.toml"));
}
#[test]
fn explain_tracks_env_per_key_and_harness_per_field() {
let report = explain(&layers(
"[env]\nA = \"user\"\nB = \"user\"\n[harness.claude-code]\nbin = \"/u/claude\"",
"[env]\nB = \"project\"\n[harness.claude-code]\nmodel = \"sonnet\"",
));
assert_eq!(report.env["A"].source.as_deref(), Some("/user.toml"));
assert_eq!(report.env["B"].value.as_deref(), Some("project"));
assert_eq!(report.env["B"].source.as_deref(), Some("/project.toml"));
let claude = &report.harness["claude-code"];
assert_eq!(claude.bin.source.as_deref(), Some("/user.toml"));
assert_eq!(claude.model.value.as_deref(), Some("sonnet"));
assert_eq!(claude.model.source.as_deref(), Some("/project.toml"));
assert_eq!(claude.args, Field::unset());
}
#[test]
fn env_for_layers_global_then_harness() {
let c = parsed("[env]\nA = \"global\"\nB = \"global\"\n[harness.qwen.env]\nB = \"qwen\"");
assert_eq!(
c.env_for("qwen"),
vec![
("A".to_string(), "global".to_string()),
("B".to_string(), "global".to_string()),
("B".to_string(), "qwen".to_string()),
]
);
assert_eq!(
c.env_for("codex"),
vec![
("A".to_string(), "global".to_string()),
("B".to_string(), "global".to_string()),
]
);
}
fn env_get<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
}
#[test]
fn from_env_unset_contributes_no_layer() {
assert_eq!(from_env(env_get(&[])).unwrap(), None);
assert_eq!(
from_env(env_get(&[("ONEHARNESS_MODEL", "")])).unwrap(),
None
);
}
#[test]
fn from_env_maps_every_field_with_standard_names() {
let c = from_env(env_get(&[
("ONEHARNESS_HARNESSES", "claude-code, codex"),
("ONEHARNESS_EXCLUDE", "cursor"),
("ONEHARNESS_MODEL", "haiku"),
("ONEHARNESS_SYSTEM", "be terse"),
("ONEHARNESS_BYPASS", "false"),
("ONEHARNESS_MODE", "plan"),
("ONEHARNESS_TIMEOUT", "90"),
("ONEHARNESS_OUTPUT_FORMAT", "stream-json"),
("ONEHARNESS_SCHEMA_FILE", "schema.json"),
("ONEHARNESS_SCHEMA_MAX_RETRIES", "4"),
("ONEHARNESS_MAX_PARALLEL", "2"),
("ONEHARNESS_REQUIRE_AVAILABLE", "1"),
]))
.unwrap()
.unwrap();
assert_eq!(c.harnesses.as_deref().unwrap(), ["claude-code", "codex"]);
assert_eq!(c.exclude.as_deref().unwrap(), ["cursor"]);
assert_eq!(c.model.as_deref(), Some("haiku"));
assert_eq!(c.system.as_deref(), Some("be terse"));
assert_eq!(c.bypass, Some(false));
assert_eq!(c.mode, Some(PermissionMode::Plan));
assert_eq!(c.timeout, Some(90));
assert_eq!(c.output_format, Some(OutputFormat::StreamJson));
assert_eq!(c.schema_file.as_deref(), Some("schema.json"));
assert_eq!(c.schema_max_retries, Some(4));
assert_eq!(c.max_parallel, Some(2));
assert_eq!(c.require_available, Some(true));
}
#[test]
fn from_env_all_true_selects_everything() {
let c = from_env(env_get(&[("ONEHARNESS_ALL", "true")]))
.unwrap()
.unwrap();
assert_eq!(c.all, Some(true));
}
#[test]
fn from_env_rejects_malformed_values() {
for (pairs, needle) in [
(vec![("ONEHARNESS_BYPASS", "maybe")], "`true` or `false`"),
(vec![("ONEHARNESS_TIMEOUT", "soon")], "non-negative integer"),
(
vec![("ONEHARNESS_MAX_PARALLEL", "-1")],
"non-negative integer",
),
(vec![("ONEHARNESS_OUTPUT_FORMAT", "yaml")], "text"),
(vec![("ONEHARNESS_MODE", "yolo")], "bypass"),
] {
let err = from_env(env_get(&pairs)).unwrap_err();
assert!(err.contains(needle), "{pairs:?} -> {err}");
}
}
#[test]
fn mode_layers_and_explains_per_field() {
let merged = merge(parsed("mode = \"plan\""), parsed("mode = \"bypass\""));
assert_eq!(merged.mode, Some(PermissionMode::Bypass));
let report = explain(&layers("mode = \"plan\"", "mode = \"edit\""));
assert_eq!(report.mode.value, Some(PermissionMode::Edit));
assert_eq!(report.mode.source.as_deref(), Some("/project.toml"));
assert_eq!(explain(&[]).mode, Field::unset());
}
#[test]
fn from_env_runs_the_same_validation_as_a_file() {
let err = from_env(env_get(&[("ONEHARNESS_HARNESSES", "bogus")])).unwrap_err();
assert!(err.contains("bogus"), "{err}");
let err = from_env(env_get(&[
("ONEHARNESS_ALL", "true"),
("ONEHARNESS_HARNESSES", "codex"),
]))
.unwrap_err();
assert!(err.contains("mutually exclusive"), "{err}");
}
#[test]
fn from_env_layer_explains_with_environment_source() {
let env = from_env(env_get(&[("ONEHARNESS_MODEL", "env-model")]))
.unwrap()
.unwrap();
let report = explain(&[
(
"/project.toml".to_string(),
parsed("model = \"file-model\""),
),
(ENV_SOURCE.to_string(), env),
]);
assert_eq!(report.model.value.as_deref(), Some("env-model"));
assert_eq!(report.model.source.as_deref(), Some(ENV_SOURCE));
}
}