1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::domain::harness;
17use crate::domain::hooks::HookSpec;
18use crate::domain::mode::PermissionMode;
19use crate::domain::report::OutputFormat;
20
21#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct FileConfig {
27 pub all: Option<bool>,
30 pub harnesses: Option<Vec<String>>,
32 pub exclude: Option<Vec<String>>,
34 pub model: Option<String>,
36 pub system: Option<String>,
38 pub bypass: Option<bool>,
43 pub mode: Option<PermissionMode>,
47 pub timeout: Option<u64>,
49 pub output_format: Option<OutputFormat>,
51 pub schema_file: Option<String>,
57 pub schema_max_retries: Option<u32>,
60 pub max_parallel: Option<usize>,
62 pub require_available: Option<bool>,
64 pub history: Option<bool>,
68 pub history_dir: Option<String>,
71 pub allowed_tools: Option<Vec<String>>,
79 pub denied_tools: Option<Vec<String>>,
82 pub hooks: Option<Vec<HookEntry>>,
90 #[serde(default)]
92 pub env: BTreeMap<String, String>,
93 #[serde(default)]
95 pub harness: BTreeMap<String, HarnessConfig>,
96}
97
98#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct HarnessConfig {
103 pub model: Option<String>,
105 pub bin: Option<String>,
108 pub args: Option<Vec<String>>,
111 pub allowed_tools: Option<Vec<String>>,
116 pub denied_tools: Option<Vec<String>>,
118 pub hooks: Option<toml::Value>,
123 pub settings: Option<toml::Value>,
128 #[serde(default)]
130 pub env: BTreeMap<String, String>,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
137#[serde(deny_unknown_fields)]
138pub struct HookEntry {
139 pub command: String,
142 pub matcher: Option<String>,
145 pub timeout: Option<u64>,
148 pub plugin_name: Option<String>,
152 pub harnesses: Option<Vec<String>>,
155}
156
157pub fn parse(text: &str) -> Result<FileConfig, String> {
161 let config: FileConfig = toml::from_str(text).map_err(|e| e.to_string())?;
162 validate(&config)?;
163 Ok(config)
164}
165
166fn validate(config: &FileConfig) -> Result<(), String> {
167 if config.all == Some(true) && config.harnesses.is_some() {
168 return Err("`all = true` and `harnesses` are mutually exclusive".to_string());
169 }
170 let hook_harnesses = config
171 .hooks
172 .iter()
173 .flatten()
174 .flat_map(|h| h.harnesses.iter().flatten());
175 let named = config
176 .harnesses
177 .iter()
178 .flatten()
179 .chain(config.exclude.iter().flatten())
180 .chain(hook_harnesses)
181 .map(String::as_str)
182 .chain(config.harness.keys().map(String::as_str));
183 for id in named {
184 if harness::by_id(id).is_none() {
185 return Err(format!(
186 "unknown harness id `{id}`. valid ids: {}",
187 harness::valid_ids()
188 ));
189 }
190 }
191 for entry in config.hooks.iter().flatten() {
195 if entry.command.trim().is_empty() {
196 return Err("a `[[hooks]]` entry needs a non-empty `command`".to_string());
197 }
198 for id in entry.harnesses.iter().flatten() {
199 if harness::by_id(id)
201 .expect("hook harness id validated")
202 .hooks
203 .is_none()
204 {
205 return Err(format!(
206 "`[[hooks]]` targets harness `{id}`, which oneharness cannot install a hook into"
207 ));
208 }
209 }
210 }
211 for (id, h) in &config.harness {
215 let spec = harness::by_id(id).expect("ids validated above");
216 let sync = spec.sync.as_ref();
217 let unsupported = [
218 (h.allowed_tools.is_some() && sync.and_then(|s| s.allow_path).is_none())
219 .then_some("allowed_tools"),
220 (h.denied_tools.is_some() && sync.and_then(|s| s.deny_path).is_none())
221 .then_some("denied_tools"),
222 (h.hooks.is_some() && sync.and_then(|s| s.hooks_path).is_none()).then_some("hooks"),
223 (h.settings.is_some() && sync.is_none()).then_some("settings"),
224 ];
225 if let Some(setting) = unsupported.into_iter().flatten().next() {
226 return Err(format!(
227 "`oneharness sync` cannot deliver `{setting}` for harness `{id}`: its \
228 config file has no mapping for it. Use `[harness.{id}.settings]` if the \
229 harness has a config file (see `sync_file` in `oneharness list`), or \
230 `[harness.{id}] args` for flag-based harnesses"
231 ));
232 }
233 for (name, value) in [("hooks", &h.hooks), ("settings", &h.settings)] {
236 if value.as_ref().is_some_and(|v| !v.is_table()) {
237 return Err(format!(
238 "`{name}` for harness `{id}` must be a table (e.g. `[harness.{id}.{name}]`)"
239 ));
240 }
241 }
242 }
243 for key in config
244 .env
245 .keys()
246 .chain(config.harness.values().flat_map(|h| h.env.keys()))
247 {
248 if key.is_empty() {
249 return Err("environment variable names must be non-empty".to_string());
250 }
251 }
252 Ok(())
253}
254
255pub const ENV_SOURCE: &str = "environment";
259
260pub fn from_env(get: impl Fn(&str) -> Option<String>) -> Result<Option<FileConfig>, String> {
278 let read = |name: &str| get(name).filter(|v| !v.is_empty());
279
280 let config = FileConfig {
281 all: env_bool(&read, "ONEHARNESS_ALL")?,
282 harnesses: env_list(&read, "ONEHARNESS_HARNESSES"),
283 exclude: env_list(&read, "ONEHARNESS_EXCLUDE"),
284 model: read("ONEHARNESS_MODEL"),
285 system: read("ONEHARNESS_SYSTEM"),
286 bypass: env_bool(&read, "ONEHARNESS_BYPASS")?,
287 mode: env_mode(&read)?,
288 timeout: env_num(&read, "ONEHARNESS_TIMEOUT", "a non-negative integer")?,
289 output_format: env_output_format(&read)?,
290 schema_file: read("ONEHARNESS_SCHEMA_FILE"),
291 schema_max_retries: env_num(
292 &read,
293 "ONEHARNESS_SCHEMA_MAX_RETRIES",
294 "a non-negative integer",
295 )?,
296 max_parallel: env_num(&read, "ONEHARNESS_MAX_PARALLEL", "a non-negative integer")?,
297 require_available: env_bool(&read, "ONEHARNESS_REQUIRE_AVAILABLE")?,
298 history: env_bool(&read, "ONEHARNESS_HISTORY")?,
299 history_dir: read("ONEHARNESS_HISTORY_DIR"),
300 ..FileConfig::default()
301 };
302
303 if config == FileConfig::default() {
306 return Ok(None);
307 }
308 validate(&config)?;
309 Ok(Some(config))
310}
311
312fn env_bool<F: Fn(&str) -> Option<String>>(read: &F, name: &str) -> Result<Option<bool>, String> {
314 match read(name) {
315 None => Ok(None),
316 Some(v) => match v.to_ascii_lowercase().as_str() {
317 "true" | "1" => Ok(Some(true)),
318 "false" | "0" => Ok(Some(false)),
319 _ => Err(format!("`{name}` must be `true` or `false`, got `{v}`")),
320 },
321 }
322}
323
324fn env_num<T, F>(read: &F, name: &str, what: &str) -> Result<Option<T>, String>
327where
328 T: std::str::FromStr,
329 F: Fn(&str) -> Option<String>,
330{
331 match read(name) {
332 None => Ok(None),
333 Some(v) => v
334 .parse::<T>()
335 .map(Some)
336 .map_err(|_| format!("`{name}` must be {what}, got `{v}`")),
337 }
338}
339
340fn env_list<F: Fn(&str) -> Option<String>>(read: &F, name: &str) -> Option<Vec<String>> {
344 read(name).map(|v| {
345 v.split(',')
346 .map(str::trim)
347 .filter(|s| !s.is_empty())
348 .map(str::to_string)
349 .collect()
350 })
351}
352
353fn env_mode<F: Fn(&str) -> Option<String>>(read: &F) -> Result<Option<PermissionMode>, String> {
355 let name = "ONEHARNESS_MODE";
356 match read(name) {
357 None => Ok(None),
358 Some(v) => PermissionMode::parse(&v)
359 .map(Some)
360 .map_err(|e| format!("`{name}` {e}")),
361 }
362}
363
364fn env_output_format<F: Fn(&str) -> Option<String>>(
366 read: &F,
367) -> Result<Option<OutputFormat>, String> {
368 let name = "ONEHARNESS_OUTPUT_FORMAT";
369 match read(name) {
370 None => Ok(None),
371 Some(v) => match v.as_str() {
372 "text" => Ok(Some(OutputFormat::Text)),
373 "json" => Ok(Some(OutputFormat::Json)),
374 "stream-json" => Ok(Some(OutputFormat::StreamJson)),
375 _ => Err(format!(
376 "`{name}` must be one of `text`, `json`, `stream-json`, got `{v}`"
377 )),
378 },
379 }
380}
381
382pub fn merge(base: FileConfig, over: FileConfig) -> FileConfig {
388 let (all, harnesses) = if over.all.is_some() || over.harnesses.is_some() {
389 (over.all, over.harnesses)
390 } else {
391 (base.all, base.harnesses)
392 };
393
394 let mut env = base.env;
395 env.extend(over.env);
396
397 let mut harness = base.harness;
398 for (id, o) in over.harness {
399 let entry = harness.entry(id).or_default();
400 let mut merged_env = std::mem::take(&mut entry.env);
401 merged_env.extend(o.env);
402 *entry = HarnessConfig {
403 model: o.model.or(entry.model.take()),
404 bin: o.bin.or(entry.bin.take()),
405 args: o.args.or(entry.args.take()),
406 allowed_tools: o.allowed_tools.or(entry.allowed_tools.take()),
407 denied_tools: o.denied_tools.or(entry.denied_tools.take()),
408 hooks: o.hooks.or(entry.hooks.take()),
409 settings: o.settings.or(entry.settings.take()),
410 env: merged_env,
411 };
412 }
413
414 FileConfig {
415 all,
416 harnesses,
417 exclude: over.exclude.or(base.exclude),
418 model: over.model.or(base.model),
419 system: over.system.or(base.system),
420 bypass: over.bypass.or(base.bypass),
421 mode: over.mode.or(base.mode),
422 timeout: over.timeout.or(base.timeout),
423 output_format: over.output_format.or(base.output_format),
424 schema_file: over.schema_file.or(base.schema_file),
425 schema_max_retries: over.schema_max_retries.or(base.schema_max_retries),
426 max_parallel: over.max_parallel.or(base.max_parallel),
427 require_available: over.require_available.or(base.require_available),
428 history: over.history.or(base.history),
429 history_dir: over.history_dir.or(base.history_dir),
430 allowed_tools: over.allowed_tools.or(base.allowed_tools),
431 denied_tools: over.denied_tools.or(base.denied_tools),
432 hooks: over.hooks.or(base.hooks),
433 env,
434 harness,
435 }
436}
437
438impl FileConfig {
439 pub fn model_for(&self, id: &str) -> Option<&str> {
442 self.harness
443 .get(id)
444 .and_then(|h| h.model.as_deref())
445 .or(self.model.as_deref())
446 }
447
448 pub fn bin_for(&self, id: &str) -> Option<&str> {
450 self.harness.get(id).and_then(|h| h.bin.as_deref())
451 }
452
453 pub fn args_for(&self, id: &str) -> &[String] {
455 self.harness
456 .get(id)
457 .and_then(|h| h.args.as_deref())
458 .unwrap_or(&[])
459 }
460
461 pub fn allowed_tools_for(&self, id: &str) -> &[String] {
464 self.harness
465 .get(id)
466 .and_then(|h| h.allowed_tools.as_deref())
467 .or(self.allowed_tools.as_deref())
468 .unwrap_or(&[])
469 }
470
471 pub fn denied_tools_for(&self, id: &str) -> &[String] {
473 self.harness
474 .get(id)
475 .and_then(|h| h.denied_tools.as_deref())
476 .or(self.denied_tools.as_deref())
477 .unwrap_or(&[])
478 }
479
480 pub fn hooks_for(&self, id: &str) -> Option<&toml::Value> {
483 self.harness.get(id).and_then(|h| h.hooks.as_ref())
484 }
485
486 pub fn hook_specs_for(&self, id: &str) -> Vec<HookSpec> {
491 self.hooks
492 .iter()
493 .flatten()
494 .filter(|entry| match &entry.harnesses {
495 Some(ids) => ids.iter().any(|h| h == id),
496 None => true,
497 })
498 .map(|entry| HookSpec {
499 command: entry.command.replace("{harness}", id),
500 matcher: entry.matcher.clone(),
501 timeout: entry.timeout,
502 plugin_name: entry.plugin_name.clone(),
503 description: None,
504 })
505 .collect()
506 }
507
508 pub fn settings_for(&self, id: &str) -> Option<&toml::Value> {
511 self.harness.get(id).and_then(|h| h.settings.as_ref())
512 }
513
514 pub fn env_for(&self, id: &str) -> Vec<(String, String)> {
519 let mut env: Vec<(String, String)> = self
520 .env
521 .iter()
522 .map(|(k, v)| (k.clone(), v.clone()))
523 .collect();
524 if let Some(h) = self.harness.get(id) {
525 env.extend(h.env.iter().map(|(k, v)| (k.clone(), v.clone())));
526 }
527 env
528 }
529}
530
531pub const DEFAULT_SOURCE: &str = "default";
534
535#[derive(Debug, Clone, PartialEq, Serialize)]
539pub struct Field<T> {
540 pub value: Option<T>,
541 pub source: Option<String>,
542}
543
544impl<T> Field<T> {
545 fn unset() -> Self {
546 Self {
547 value: None,
548 source: None,
549 }
550 }
551
552 fn default_value(value: T) -> Self {
553 Self {
554 value: Some(value),
555 source: Some(DEFAULT_SOURCE.to_string()),
556 }
557 }
558
559 fn or_default(self, value: T) -> Self {
560 if self.value.is_some() {
561 self
562 } else {
563 Self::default_value(value)
564 }
565 }
566}
567
568#[derive(Debug, Serialize)]
572pub struct ConfigReport {
573 pub schema_version: &'static str,
574 pub config_files: Vec<String>,
576 pub all: Field<bool>,
577 pub harnesses: Field<Vec<String>>,
578 pub exclude: Field<Vec<String>>,
579 pub model: Field<String>,
580 pub system: Field<String>,
581 pub bypass: Field<bool>,
582 pub mode: Field<PermissionMode>,
585 pub timeout: Field<u64>,
586 pub output_format: Field<OutputFormat>,
587 pub schema_file: Field<String>,
588 pub schema_max_retries: Field<u32>,
589 pub max_parallel: Field<usize>,
590 pub require_available: Field<bool>,
591 pub history: Field<bool>,
592 pub history_dir: Field<String>,
593 pub allowed_tools: Field<Vec<String>>,
594 pub denied_tools: Field<Vec<String>>,
595 pub hooks: Field<Vec<HookEntry>>,
596 pub env: BTreeMap<String, Field<String>>,
598 pub harness: BTreeMap<String, HarnessReport>,
600}
601
602#[derive(Debug, Serialize)]
604pub struct HarnessReport {
605 pub model: Field<String>,
606 pub bin: Field<String>,
607 pub args: Field<Vec<String>>,
608 pub allowed_tools: Field<Vec<String>>,
609 pub denied_tools: Field<Vec<String>>,
610 pub hooks: Field<toml::Value>,
611 pub settings: Field<toml::Value>,
612 pub env: BTreeMap<String, Field<String>>,
613}
614
615fn pick<T: Clone>(
618 layers: &[(String, FileConfig)],
619 get: impl Fn(&FileConfig) -> Option<T>,
620) -> Field<T> {
621 let mut field = Field::unset();
622 for (path, config) in layers {
623 if let Some(value) = get(config) {
624 field = Field {
625 value: Some(value),
626 source: Some(path.clone()),
627 };
628 }
629 }
630 field
631}
632
633pub fn explain(layers: &[(String, FileConfig)]) -> ConfigReport {
637 let (all, harnesses) = layers
641 .iter()
642 .rev()
643 .find(|(_, c)| c.all.is_some() || c.harnesses.is_some())
644 .map(|(path, c)| {
645 (
646 Field {
647 source: c.all.is_some().then(|| path.clone()),
648 value: c.all,
649 },
650 Field {
651 source: c.harnesses.is_some().then(|| path.clone()),
652 value: c.harnesses.clone(),
653 },
654 )
655 })
656 .unwrap_or((Field::unset(), Field::unset()));
657
658 let mut env: BTreeMap<String, Field<String>> = BTreeMap::new();
659 for (path, config) in layers {
660 for (key, value) in &config.env {
661 env.insert(
662 key.clone(),
663 Field {
664 value: Some(value.clone()),
665 source: Some(path.clone()),
666 },
667 );
668 }
669 }
670
671 let mut harness: BTreeMap<String, HarnessReport> = BTreeMap::new();
672 let ids: std::collections::BTreeSet<&String> =
673 layers.iter().flat_map(|(_, c)| c.harness.keys()).collect();
674 for id in ids {
675 let section = |c: &FileConfig| c.harness.get(id).cloned();
676 let mut h_env: BTreeMap<String, Field<String>> = BTreeMap::new();
677 for (path, config) in layers {
678 if let Some(h) = config.harness.get(id) {
679 for (key, value) in &h.env {
680 h_env.insert(
681 key.clone(),
682 Field {
683 value: Some(value.clone()),
684 source: Some(path.clone()),
685 },
686 );
687 }
688 }
689 }
690 harness.insert(
691 id.clone(),
692 HarnessReport {
693 model: pick(layers, |c| section(c).and_then(|h| h.model)),
694 bin: pick(layers, |c| section(c).and_then(|h| h.bin)),
695 args: pick(layers, |c| section(c).and_then(|h| h.args)),
696 allowed_tools: pick(layers, |c| section(c).and_then(|h| h.allowed_tools)),
697 denied_tools: pick(layers, |c| section(c).and_then(|h| h.denied_tools)),
698 hooks: pick(layers, |c| section(c).and_then(|h| h.hooks)),
699 settings: pick(layers, |c| section(c).and_then(|h| h.settings)),
700 env: h_env,
701 },
702 );
703 }
704
705 ConfigReport {
706 schema_version: crate::domain::report::SCHEMA_VERSION,
707 config_files: layers.iter().map(|(path, _)| path.clone()).collect(),
708 all,
709 harnesses,
710 exclude: pick(layers, |c| c.exclude.clone()),
711 model: pick(layers, |c| c.model.clone()),
712 system: pick(layers, |c| c.system.clone()),
713 bypass: pick(layers, |c| c.bypass).or_default(false),
716 mode: pick(layers, |c| c.mode),
717 timeout: pick(layers, |c| c.timeout).or_default(120),
718 output_format: pick(layers, |c| c.output_format),
719 schema_file: pick(layers, |c| c.schema_file.clone()),
720 schema_max_retries: pick(layers, |c| c.schema_max_retries),
721 max_parallel: pick(layers, |c| c.max_parallel),
722 require_available: pick(layers, |c| c.require_available).or_default(false),
723 history: pick(layers, |c| c.history).or_default(false),
724 history_dir: pick(layers, |c| c.history_dir.clone()),
725 allowed_tools: pick(layers, |c| c.allowed_tools.clone()),
726 denied_tools: pick(layers, |c| c.denied_tools.clone()),
727 hooks: pick(layers, |c| c.hooks.clone()),
728 env,
729 harness,
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 fn parsed(text: &str) -> FileConfig {
738 parse(text).expect("config should parse")
739 }
740
741 #[test]
742 fn empty_config_is_all_defaults() {
743 let c = parsed("");
744 assert_eq!(c, FileConfig::default());
745 }
746
747 #[test]
748 fn full_config_round_trips() {
749 let c = parsed(
750 r#"
751 harnesses = ["claude-code", "codex"]
752 exclude = ["cursor"]
753 model = "haiku"
754 system = "be terse"
755 bypass = false
756 mode = "plan"
757 timeout = 90
758 output_format = "stream-json"
759 schema_file = "schema.json"
760 schema_max_retries = 4
761 max_parallel = 2
762 require_available = true
763 history = true
764 history_dir = "/var/hist"
765
766 [env]
767 FOO = "bar"
768
769 [harness.claude-code]
770 model = "sonnet"
771 bin = "/opt/claude"
772 args = ["--max-turns", "6"]
773 env = { BAZ = "qux" }
774 "#,
775 );
776 assert_eq!(c.harnesses.as_deref().unwrap(), ["claude-code", "codex"]);
777 assert_eq!(c.exclude.as_deref().unwrap(), ["cursor"]);
778 assert_eq!(c.model.as_deref(), Some("haiku"));
779 assert_eq!(c.bypass, Some(false));
780 assert_eq!(c.mode, Some(PermissionMode::Plan));
781 assert_eq!(c.timeout, Some(90));
782 assert_eq!(c.output_format, Some(OutputFormat::StreamJson));
783 assert_eq!(c.schema_file.as_deref(), Some("schema.json"));
784 assert_eq!(c.schema_max_retries, Some(4));
785 assert_eq!(c.max_parallel, Some(2));
786 assert_eq!(c.require_available, Some(true));
787 assert_eq!(c.history, Some(true));
788 assert_eq!(c.history_dir.as_deref(), Some("/var/hist"));
789 assert_eq!(c.env["FOO"], "bar");
790 assert_eq!(c.model_for("claude-code"), Some("sonnet"));
791 assert_eq!(c.model_for("codex"), Some("haiku"));
792 assert_eq!(c.bin_for("claude-code"), Some("/opt/claude"));
793 assert_eq!(c.args_for("claude-code"), ["--max-turns", "6"]);
794 assert_eq!(c.args_for("codex"), Vec::<String>::new().as_slice());
795 }
796
797 #[test]
798 fn normalized_hooks_filter_and_substitute_per_harness() {
799 let c = parsed(
800 r#"
801 [[hooks]]
802 command = "mygate hook {harness}"
803 matcher = "Bash"
804 timeout = 10
805
806 [[hooks]]
807 command = "extra hook"
808 harnesses = ["codex"]
809 "#,
810 );
811 let claude = c.hook_specs_for("claude-code");
814 assert_eq!(claude.len(), 1);
815 assert_eq!(claude[0].command, "mygate hook claude-code");
816 assert_eq!(claude[0].matcher.as_deref(), Some("Bash"));
817 assert_eq!(claude[0].timeout, Some(10));
818
819 let codex = c.hook_specs_for("codex");
820 assert_eq!(codex.len(), 2);
821 assert_eq!(codex[0].command, "mygate hook codex");
822 assert_eq!(codex[1].command, "extra hook");
823 }
824
825 #[test]
826 fn hooks_merge_replaces_the_list_per_layer() {
827 let base = parsed("[[hooks]]\ncommand = \"user gate\"");
828 let over = parsed("[[hooks]]\ncommand = \"project gate\"");
829 let merged = merge(base, over);
830 let specs = merged.hook_specs_for("claude-code");
831 assert_eq!(specs.len(), 1);
832 assert_eq!(specs[0].command, "project gate");
833 }
834
835 #[test]
836 fn an_empty_hook_command_is_rejected() {
837 let err = parse("[[hooks]]\ncommand = \" \"").unwrap_err();
838 assert!(err.contains("non-empty `command`"), "{err}");
839 }
840
841 #[test]
842 fn a_hook_targeting_an_unknown_harness_is_rejected() {
843 let err = parse("[[hooks]]\ncommand = \"x\"\nharnesses = [\"bogus\"]").unwrap_err();
844 assert!(err.contains("bogus"), "{err}");
845 }
846
847 #[test]
848 fn unknown_top_level_field_is_rejected() {
849 let err = parse("modle = \"typo\"").unwrap_err();
850 assert!(err.contains("modle"), "{err}");
851 }
852
853 #[test]
854 fn unknown_per_harness_field_is_rejected() {
855 assert!(parse("[harness.claude-code]\nmodle = \"x\"").is_err());
856 }
857
858 #[test]
859 fn unknown_harness_id_is_rejected_everywhere() {
860 for text in [
861 "harnesses = [\"bogus\"]",
862 "exclude = [\"bogus\"]",
863 "[harness.bogus]\nmodel = \"x\"",
864 ] {
865 let err = parse(text).unwrap_err();
866 assert!(err.contains("bogus"), "{text} -> {err}");
867 }
868 }
869
870 #[test]
871 fn all_and_harnesses_together_are_rejected() {
872 let err = parse("all = true\nharnesses = [\"codex\"]").unwrap_err();
873 assert!(err.contains("mutually exclusive"), "{err}");
874 }
875
876 #[test]
877 fn allow_deny_and_hooks_parse_at_both_levels() {
878 let c = parsed(
879 r#"
880 allowed_tools = ["Bash(git log:*)"]
881 denied_tools = ["Bash(rm:*)"]
882
883 [harness.cursor]
884 allowed_tools = ["Shell(git)"]
885
886 [harness.claude-code.hooks]
887 PreToolUse = [{ matcher = "Bash", hooks = [{ type = "command", command = "./check.sh" }] }]
888 "#,
889 );
890 assert_eq!(c.allowed_tools_for("cursor"), ["Shell(git)"]);
892 assert_eq!(c.allowed_tools_for("claude-code"), ["Bash(git log:*)"]);
893 assert_eq!(c.denied_tools_for("claude-code"), ["Bash(rm:*)"]);
894 assert!(c.hooks_for("claude-code").is_some());
895 assert!(c.hooks_for("cursor").is_none());
896 }
897
898 #[test]
899 fn sync_settings_without_a_file_mapping_are_rejected_at_parse() {
900 for (text, what) in [
904 ("[harness.codex]\nallowed_tools = [\"x\"]", "allowed_tools"),
905 (
906 "[harness.copilot]\nallowed_tools = [\"x\"]",
907 "allowed_tools",
908 ),
909 (
910 "[harness.opencode]\nallowed_tools = [\"x\"]",
911 "allowed_tools",
912 ),
913 ("[harness.crush]\nhooks = {}", "hooks"),
914 ("[harness.cursor.hooks]\nbeforeShellExecution = []", "hooks"),
915 (
916 "[harness.goose.settings]\nGOOSE_MODE = \"auto\"",
917 "settings",
918 ),
919 ] {
920 let err = parse(text).unwrap_err();
921 assert!(err.contains(what), "{text} -> {err}");
922 assert!(err.contains("cannot deliver"), "{text} -> {err}");
923 }
924 for text in [
928 "[harness.qwen]\ndenied_tools = [\"Shell\"]",
929 "[harness.crush]\ndenied_tools = [\"bash\"]",
930 "[harness.cursor]\nallowed_tools = [\"Shell(ls)\"]",
931 "[harness.claude-code.hooks]\nPreToolUse = []",
932 "[harness.opencode.settings.permission]\nedit = \"deny\"",
933 ] {
934 assert!(parse(text).is_ok(), "{text} should parse");
935 }
936 }
937
938 #[test]
939 fn non_table_hooks_and_settings_are_rejected_at_parse() {
940 for text in [
943 "[harness.claude-code]\nhooks = \"oops\"",
944 "[harness.claude-code]\nsettings = 3",
945 "[harness.opencode]\nsettings = [\"list\"]",
946 ] {
947 let err = parse(text).unwrap_err();
948 assert!(err.contains("must be a table"), "{text} -> {err}");
949 }
950 }
951
952 #[test]
953 fn merge_replaces_settings_tables_per_layer() {
954 let base = parsed("[harness.opencode.settings.permission]\nedit = \"deny\"");
958 let over = parsed("[harness.opencode.settings.permission.bash]\n\"git *\" = \"allow\"");
959 let merged = merge(base, over);
960 let settings = merged.settings_for("opencode").unwrap();
961 assert!(settings.get("permission").unwrap().get("bash").is_some());
962 assert!(
963 settings.get("permission").unwrap().get("edit").is_none(),
964 "tables replace across layers, not merge"
965 );
966 }
967
968 #[test]
969 fn merge_replaces_rule_lists_per_field() {
970 let base = parsed("allowed_tools = [\"user-rule\"]\ndenied_tools = [\"user-deny\"]");
971 let over = parsed("allowed_tools = [\"project-rule\"]");
972 let merged = merge(base, over);
973 assert_eq!(merged.allowed_tools.as_deref().unwrap(), ["project-rule"]);
974 assert_eq!(merged.denied_tools.as_deref().unwrap(), ["user-deny"]);
975
976 let base = parsed("[harness.claude-code.hooks]\nPreToolUse = []");
978 let over = parsed("[harness.claude-code.hooks]\nPostToolUse = []");
979 let merged = merge(base, over);
980 let hooks = merged.hooks_for("claude-code").unwrap();
981 assert!(hooks.get("PostToolUse").is_some());
982 assert!(
983 hooks.get("PreToolUse").is_none(),
984 "tables replace, not merge"
985 );
986 }
987
988 #[test]
989 fn explain_covers_rules_and_hooks() {
990 let report = explain(&layers(
991 "allowed_tools = [\"user-rule\"]",
992 "[harness.claude-code]\nallowed_tools = [\"claude-rule\"]\n[harness.claude-code.hooks]\nPreToolUse = []",
993 ));
994 assert_eq!(
995 report.allowed_tools.value.as_deref().unwrap(),
996 ["user-rule"]
997 );
998 assert_eq!(report.allowed_tools.source.as_deref(), Some("/user.toml"));
999 let claude = &report.harness["claude-code"];
1000 assert_eq!(
1001 claude.allowed_tools.source.as_deref(),
1002 Some("/project.toml")
1003 );
1004 assert_eq!(claude.hooks.source.as_deref(), Some("/project.toml"));
1005 }
1006
1007 #[test]
1008 fn merge_prefers_over_per_field_and_falls_through() {
1009 let base = parsed("model = \"user\"\nsystem = \"keep me\"\ntimeout = 30");
1010 let over = parsed("model = \"project\"");
1011 let merged = merge(base, over);
1012 assert_eq!(merged.model.as_deref(), Some("project"));
1013 assert_eq!(merged.system.as_deref(), Some("keep me"));
1014 assert_eq!(merged.timeout, Some(30));
1015 }
1016
1017 #[test]
1018 fn merge_moves_selection_as_a_unit() {
1019 let base = parsed("all = true");
1022 let over = parsed("harnesses = [\"codex\"]");
1023 let merged = merge(base, over);
1024 assert_eq!(merged.all, None);
1025 assert_eq!(merged.harnesses.as_deref().unwrap(), ["codex"]);
1026 let merged = merge(parsed("harnesses = [\"codex\"]"), parsed("all = true"));
1028 assert_eq!(merged.all, Some(true));
1029 assert_eq!(merged.harnesses, None);
1030 }
1031
1032 #[test]
1033 fn schema_fields_layer_and_explain_per_field() {
1034 let merged = merge(
1037 parsed("schema_file = \"user.json\"\nschema_max_retries = 1"),
1038 parsed("schema_max_retries = 5"),
1039 );
1040 assert_eq!(merged.schema_file.as_deref(), Some("user.json"));
1041 assert_eq!(merged.schema_max_retries, Some(5));
1042
1043 let report = explain(&layers(
1044 "schema_file = \"user.json\"",
1045 "schema_file = \"proj.json\"\nschema_max_retries = 3",
1046 ));
1047 assert_eq!(report.schema_file.value.as_deref(), Some("proj.json"));
1048 assert_eq!(report.schema_file.source.as_deref(), Some("/project.toml"));
1049 assert_eq!(report.schema_max_retries.value, Some(3));
1050 let report = explain(&[]);
1052 assert_eq!(report.schema_max_retries, Field::unset());
1053 assert_eq!(report.schema_file, Field::unset());
1054 }
1055
1056 #[test]
1057 fn merge_env_is_keywise_with_over_winning() {
1058 let base = parsed("[env]\nA = \"base\"\nB = \"base\"");
1059 let over = parsed("[env]\nB = \"over\"\nC = \"over\"");
1060 let merged = merge(base, over);
1061 assert_eq!(merged.env["A"], "base");
1062 assert_eq!(merged.env["B"], "over");
1063 assert_eq!(merged.env["C"], "over");
1064 }
1065
1066 #[test]
1067 fn merge_harness_tables_per_field() {
1068 let base = parsed("[harness.claude-code]\nmodel = \"user\"\nbin = \"/usr/bin/claude\"");
1069 let over = parsed("[harness.claude-code]\nmodel = \"project\"");
1070 let merged = merge(base, over);
1071 assert_eq!(merged.model_for("claude-code"), Some("project"));
1072 assert_eq!(merged.bin_for("claude-code"), Some("/usr/bin/claude"));
1073 }
1074
1075 fn layers(user: &str, project: &str) -> Vec<(String, FileConfig)> {
1076 vec![
1077 ("/user.toml".to_string(), parsed(user)),
1078 ("/project.toml".to_string(), parsed(project)),
1079 ]
1080 }
1081
1082 #[test]
1083 fn explain_with_no_layers_shows_only_built_in_defaults() {
1084 let report = explain(&[]);
1085 assert!(report.config_files.is_empty());
1086 assert_eq!(report.model, Field::unset());
1087 assert_eq!(report.bypass, Field::default_value(false));
1088 assert_eq!(report.timeout, Field::default_value(120));
1089 assert_eq!(report.require_available, Field::default_value(false));
1090 assert!(report.env.is_empty());
1091 assert!(report.harness.is_empty());
1092 }
1093
1094 #[test]
1095 fn explain_attributes_each_field_to_its_winning_layer() {
1096 let report = explain(&layers(
1097 "model = \"user\"\ntimeout = 30",
1098 "model = \"project\"",
1099 ));
1100 assert_eq!(report.config_files, ["/user.toml", "/project.toml"]);
1101 assert_eq!(report.model.value.as_deref(), Some("project"));
1102 assert_eq!(report.model.source.as_deref(), Some("/project.toml"));
1103 assert_eq!(report.timeout.value, Some(30));
1104 assert_eq!(report.timeout.source.as_deref(), Some("/user.toml"));
1105 assert_eq!(report.bypass.source.as_deref(), Some(DEFAULT_SOURCE));
1107 }
1108
1109 #[test]
1110 fn explain_moves_selection_as_a_unit_like_merge() {
1111 let report = explain(&layers("all = true", "harnesses = [\"codex\"]"));
1112 assert_eq!(report.all, Field::unset());
1113 assert_eq!(report.harnesses.value.as_deref().unwrap(), ["codex"]);
1114 assert_eq!(report.harnesses.source.as_deref(), Some("/project.toml"));
1115 }
1116
1117 #[test]
1118 fn explain_tracks_env_per_key_and_harness_per_field() {
1119 let report = explain(&layers(
1120 "[env]\nA = \"user\"\nB = \"user\"\n[harness.claude-code]\nbin = \"/u/claude\"",
1121 "[env]\nB = \"project\"\n[harness.claude-code]\nmodel = \"sonnet\"",
1122 ));
1123 assert_eq!(report.env["A"].source.as_deref(), Some("/user.toml"));
1124 assert_eq!(report.env["B"].value.as_deref(), Some("project"));
1125 assert_eq!(report.env["B"].source.as_deref(), Some("/project.toml"));
1126 let claude = &report.harness["claude-code"];
1127 assert_eq!(claude.bin.source.as_deref(), Some("/user.toml"));
1128 assert_eq!(claude.model.value.as_deref(), Some("sonnet"));
1129 assert_eq!(claude.model.source.as_deref(), Some("/project.toml"));
1130 assert_eq!(claude.args, Field::unset());
1131 }
1132
1133 #[test]
1134 fn env_for_layers_global_then_harness() {
1135 let c = parsed("[env]\nA = \"global\"\nB = \"global\"\n[harness.qwen.env]\nB = \"qwen\"");
1136 assert_eq!(
1137 c.env_for("qwen"),
1138 vec![
1139 ("A".to_string(), "global".to_string()),
1140 ("B".to_string(), "global".to_string()),
1141 ("B".to_string(), "qwen".to_string()),
1142 ]
1143 );
1144 assert_eq!(
1145 c.env_for("codex"),
1146 vec![
1147 ("A".to_string(), "global".to_string()),
1148 ("B".to_string(), "global".to_string()),
1149 ]
1150 );
1151 }
1152
1153 fn env_get<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
1156 move |name: &str| {
1157 pairs
1158 .iter()
1159 .find(|(k, _)| *k == name)
1160 .map(|(_, v)| v.to_string())
1161 }
1162 }
1163
1164 #[test]
1165 fn from_env_unset_contributes_no_layer() {
1166 assert_eq!(from_env(env_get(&[])).unwrap(), None);
1167 assert_eq!(
1169 from_env(env_get(&[("ONEHARNESS_MODEL", "")])).unwrap(),
1170 None
1171 );
1172 }
1173
1174 #[test]
1175 fn from_env_maps_every_field_with_standard_names() {
1176 let c = from_env(env_get(&[
1177 ("ONEHARNESS_HARNESSES", "claude-code, codex"),
1178 ("ONEHARNESS_EXCLUDE", "cursor"),
1179 ("ONEHARNESS_MODEL", "haiku"),
1180 ("ONEHARNESS_SYSTEM", "be terse"),
1181 ("ONEHARNESS_BYPASS", "false"),
1182 ("ONEHARNESS_MODE", "plan"),
1183 ("ONEHARNESS_TIMEOUT", "90"),
1184 ("ONEHARNESS_OUTPUT_FORMAT", "stream-json"),
1185 ("ONEHARNESS_SCHEMA_FILE", "schema.json"),
1186 ("ONEHARNESS_SCHEMA_MAX_RETRIES", "4"),
1187 ("ONEHARNESS_MAX_PARALLEL", "2"),
1188 ("ONEHARNESS_REQUIRE_AVAILABLE", "1"),
1189 ("ONEHARNESS_HISTORY", "true"),
1190 ("ONEHARNESS_HISTORY_DIR", "/var/hist"),
1191 ]))
1192 .unwrap()
1193 .unwrap();
1194 assert_eq!(c.harnesses.as_deref().unwrap(), ["claude-code", "codex"]);
1195 assert_eq!(c.exclude.as_deref().unwrap(), ["cursor"]);
1196 assert_eq!(c.model.as_deref(), Some("haiku"));
1197 assert_eq!(c.system.as_deref(), Some("be terse"));
1198 assert_eq!(c.bypass, Some(false));
1199 assert_eq!(c.mode, Some(PermissionMode::Plan));
1200 assert_eq!(c.timeout, Some(90));
1201 assert_eq!(c.output_format, Some(OutputFormat::StreamJson));
1202 assert_eq!(c.schema_file.as_deref(), Some("schema.json"));
1203 assert_eq!(c.schema_max_retries, Some(4));
1204 assert_eq!(c.max_parallel, Some(2));
1205 assert_eq!(c.require_available, Some(true));
1206 assert_eq!(c.history, Some(true));
1207 assert_eq!(c.history_dir.as_deref(), Some("/var/hist"));
1208 }
1209
1210 #[test]
1211 fn history_layers_and_explains_with_a_default() {
1212 let merged = merge(
1215 parsed("history = false\nhistory_dir = \"/user/h\""),
1216 parsed("history = true"),
1217 );
1218 assert_eq!(merged.history, Some(true));
1219 assert_eq!(merged.history_dir.as_deref(), Some("/user/h"));
1220
1221 let report = explain(&layers(
1222 "history_dir = \"/user/h\"",
1223 "history = true\nhistory_dir = \"/proj/h\"",
1224 ));
1225 assert_eq!(report.history.value, Some(true));
1226 assert_eq!(report.history.source.as_deref(), Some("/project.toml"));
1227 assert_eq!(report.history_dir.value.as_deref(), Some("/proj/h"));
1228 assert_eq!(report.history_dir.source.as_deref(), Some("/project.toml"));
1229 let report = explain(&[]);
1231 assert_eq!(report.history, Field::default_value(false));
1232 assert_eq!(report.history_dir, Field::unset());
1233 }
1234
1235 #[test]
1236 fn from_env_all_true_selects_everything() {
1237 let c = from_env(env_get(&[("ONEHARNESS_ALL", "true")]))
1238 .unwrap()
1239 .unwrap();
1240 assert_eq!(c.all, Some(true));
1241 }
1242
1243 #[test]
1244 fn from_env_rejects_malformed_values() {
1245 for (pairs, needle) in [
1246 (vec![("ONEHARNESS_BYPASS", "maybe")], "`true` or `false`"),
1247 (vec![("ONEHARNESS_TIMEOUT", "soon")], "non-negative integer"),
1248 (
1249 vec![("ONEHARNESS_MAX_PARALLEL", "-1")],
1250 "non-negative integer",
1251 ),
1252 (vec![("ONEHARNESS_OUTPUT_FORMAT", "yaml")], "text"),
1253 (vec![("ONEHARNESS_MODE", "yolo")], "bypass"),
1254 ] {
1255 let err = from_env(env_get(&pairs)).unwrap_err();
1256 assert!(err.contains(needle), "{pairs:?} -> {err}");
1257 }
1258 }
1259
1260 #[test]
1261 fn mode_layers_and_explains_per_field() {
1262 let merged = merge(parsed("mode = \"plan\""), parsed("mode = \"bypass\""));
1266 assert_eq!(merged.mode, Some(PermissionMode::Bypass));
1267 let report = explain(&layers("mode = \"plan\"", "mode = \"edit\""));
1268 assert_eq!(report.mode.value, Some(PermissionMode::Edit));
1269 assert_eq!(report.mode.source.as_deref(), Some("/project.toml"));
1270 assert_eq!(explain(&[]).mode, Field::unset());
1271 }
1272
1273 #[test]
1274 fn from_env_runs_the_same_validation_as_a_file() {
1275 let err = from_env(env_get(&[("ONEHARNESS_HARNESSES", "bogus")])).unwrap_err();
1277 assert!(err.contains("bogus"), "{err}");
1278 let err = from_env(env_get(&[
1279 ("ONEHARNESS_ALL", "true"),
1280 ("ONEHARNESS_HARNESSES", "codex"),
1281 ]))
1282 .unwrap_err();
1283 assert!(err.contains("mutually exclusive"), "{err}");
1284 }
1285
1286 #[test]
1287 fn from_env_layer_explains_with_environment_source() {
1288 let env = from_env(env_get(&[("ONEHARNESS_MODEL", "env-model")]))
1291 .unwrap()
1292 .unwrap();
1293 let report = explain(&[
1294 (
1295 "/project.toml".to_string(),
1296 parsed("model = \"file-model\""),
1297 ),
1298 (ENV_SOURCE.to_string(), env),
1299 ]);
1300 assert_eq!(report.model.value.as_deref(), Some("env-model"));
1301 assert_eq!(report.model.source.as_deref(), Some(ENV_SOURCE));
1302 }
1303}