Skip to main content

oneharness_core/domain/
config.rs

1//! Unified configuration: the schema of `oneharness.toml`, plus the pure
2//! parse / validate / merge logic. Discovery and reading of the actual files
3//! is I/O and lives in `src/io/config.rs`.
4//!
5//! The sources layer per field: CLI flags beat the `ONEHARNESS_*` environment
6//! overrides ([`from_env`]), which beat the project file, which beats the user
7//! file, which beats the built-in defaults. Within one resolved config, a
8//! `[harness.<id>]` value beats the top-level value for that harness. The
9//! layering — and the env layer's parsing — is resolved here, with no I/O (the
10//! env reader is injected), so it is unit-testable.
11
12use 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/// One config file, as written by the user. Every field is optional: an absent
22/// field defers to the next layer down. Unknown fields are rejected so a typo
23/// fails loudly instead of being silently ignored.
24#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct FileConfig {
27    /// Default selection: run every harness (like `--all`). Mutually exclusive
28    /// with `harnesses`. Used only when the CLI passes no selection.
29    pub all: Option<bool>,
30    /// Default selection: the harness ids to run (like `--harness`).
31    pub harnesses: Option<Vec<String>>,
32    /// Harness ids excluded from an `all` selection (like `--exclude`).
33    pub exclude: Option<Vec<String>>,
34    /// Model passed to each harness that supports a model flag (like `--model`).
35    pub model: Option<String>,
36    /// Portable system prompt (like `--system`).
37    pub system: Option<String>,
38    /// Request each harness's bypass mode (default true; like `--no-bypass`
39    /// when false). The CLI's `--bypass` / `--no-bypass` always win. Superseded
40    /// by `mode` when both are set (`mode` is the richer spelling; `bypass =
41    /// false` is exactly `mode = "default"`).
42    pub bypass: Option<bool>,
43    /// The normalized approval mode (like `--mode`): `plan` / `default` / `edit`
44    /// / `auto` / `bypass`. Beats `bypass` when both are set. The CLI's `--mode`
45    /// (and `--bypass` / `--no-bypass`) always win.
46    pub mode: Option<PermissionMode>,
47    /// Per-harness timeout in seconds (like `--timeout`).
48    pub timeout: Option<u64>,
49    /// Output format override (like `--output-format`).
50    pub output_format: Option<OutputFormat>,
51    /// Path to a JSON Schema file constraining each harness's final answer
52    /// (like `--schema`). Resolved relative to the working directory. Turns the
53    /// run into a structured-output run: the schema is delivered to the harness
54    /// (natively where supported, else via the prompt), the result is validated,
55    /// and a failure is re-prompted up to `schema_max_retries` times.
56    pub schema_file: Option<String>,
57    /// Maximum retries per harness when a response fails schema validation
58    /// (like `--schema-max-retries`; default 2). Only meaningful with a schema.
59    pub schema_max_retries: Option<u32>,
60    /// Concurrency cap (like `--max-parallel`).
61    pub max_parallel: Option<usize>,
62    /// Treat a missing harness as a failure (like `--require-available`).
63    pub require_available: Option<bool>,
64    /// Record a normalized, cross-harness history of each run to disk (opt-in,
65    /// off by default; like `--history` / `--no-history`). Most naturally set in
66    /// a user-level `config.toml` so one turns it on across every project.
67    pub history: Option<bool>,
68    /// Directory the history is written to and read from (like `--history-dir`).
69    /// Defaults to `<platform state dir>/oneharness/history` when unset.
70    pub history_dir: Option<String>,
71    /// Tool/permission rules the harness may use without prompting, in each
72    /// harness's native rule syntax. Delivered by `oneharness sync`, which
73    /// merges them into the harness's own project config file (Claude Code's
74    /// `.claude/settings.json` `permissions.allow`, Cursor's `.cursor/cli.json`,
75    /// Qwen's `.qwen/settings.json`, crush's `crush.json`) so the policy also
76    /// applies outside oneharness. A harness with no mapping reports the rule
77    /// as `unmapped` in the sync report rather than dropping it silently.
78    pub allowed_tools: Option<Vec<String>>,
79    /// Deny rules; synced like `allowed_tools` (for crush this lands in
80    /// `options.disabled_tools`, hiding the tool entirely).
81    pub denied_tools: Option<Vec<String>>,
82    /// Normalized pre-tool hooks, each fanned across the synced harnesses by
83    /// `oneharness sync` and rendered into every harness's native shape (a
84    /// shared config file, a dedicated hooks file, or a plugin). Unlike the
85    /// verbatim per-harness `[harness.<id>.hooks]` table — which is one
86    /// harness's own schema, limited to harnesses whose hooks share their
87    /// config file — a `[[hooks]]` entry is harness-agnostic and reaches all of
88    /// them. The two are independent and may both be set.
89    pub hooks: Option<Vec<HookEntry>>,
90    /// Extra environment for every harness process (like repeated `--env`).
91    #[serde(default)]
92    pub env: BTreeMap<String, String>,
93    /// Per-harness overrides, keyed by canonical harness id.
94    #[serde(default)]
95    pub harness: BTreeMap<String, HarnessConfig>,
96}
97
98/// Overrides for one harness (`[harness.<id>]`). These beat the top-level
99/// fields for that harness only — e.g. each harness can name its own model.
100#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct HarnessConfig {
103    /// Model for this harness (model names differ per provider).
104    pub model: Option<String>,
105    /// Binary name or path (like `--bin <id>=<path>`, lowest precedence:
106    /// `--bin` and `ONEHARNESS_BIN_<ID>` both beat it).
107    pub bin: Option<String>,
108    /// Extra arguments appended verbatim to this harness's command (the
109    /// configurable counterpart of the CLI's trailing `-- <args…>`).
110    pub args: Option<Vec<String>>,
111    /// Allow rules for this harness only; beats the top-level `allowed_tools`.
112    /// Rejected at parse time when the harness's config file has no allow-list
113    /// concept (`oneharness sync` could not deliver it), so a rule that cannot
114    /// apply fails loudly.
115    pub allowed_tools: Option<Vec<String>>,
116    /// Deny rules for this harness only; beats the top-level `denied_tools`.
117    pub denied_tools: Option<Vec<String>>,
118    /// Lifecycle hooks, as an uninterpreted table in the harness's own hooks
119    /// schema. Synced into the harness's config file (Claude Code's
120    /// `.claude/settings.json` `hooks` key); rejected at parse time for a
121    /// harness whose hooks oneharness has no file mapping for.
122    pub hooks: Option<toml::Value>,
123    /// Raw settings merged verbatim into this harness's project config file by
124    /// `oneharness sync` — the escape hatch for config shapes the unified
125    /// fields don't model (e.g. OpenCode's `permission` policy map). Rejected
126    /// at parse time for a harness with no known project config file.
127    pub settings: Option<toml::Value>,
128    /// Extra environment for this harness only; beats the top-level `[env]`.
129    #[serde(default)]
130    pub env: BTreeMap<String, String>,
131}
132
133/// One `[[hooks]]` entry: a pre-tool gate installed into each targeted harness.
134/// The `command` may contain `{harness}`, replaced with the harness id when the
135/// hook is built (so one entry can route `mygate hook {harness}` to every CLI).
136#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
137#[serde(deny_unknown_fields)]
138pub struct HookEntry {
139    /// The command the harness runs before a tool call. `{harness}` is
140    /// substituted with the harness id. Required and non-empty.
141    pub command: String,
142    /// Tool-name matcher in the harness's dialect (most read it as a regex);
143    /// applied to every targeted harness as written. Absent means match-all.
144    pub matcher: Option<String>,
145    /// Timeout in seconds, for the harnesses whose hook schema carries one
146    /// (Goose, Crush); ignored by the others.
147    pub timeout: Option<u64>,
148    /// Identity for plugin-delivered harnesses (Goose, OpenCode) and Copilot's
149    /// per-owner file, so two tools' hooks never collide. Defaults to
150    /// `oneharness`.
151    pub plugin_name: Option<String>,
152    /// Restrict this entry to these harness ids; absent means every harness
153    /// being synced.
154    pub harnesses: Option<Vec<String>>,
155}
156
157/// Parse one config file's text. Pure: the caller supplies the text and
158/// attaches the path to any error. Rejects unknown fields, unknown harness
159/// ids, and a selection that sets both `all` and `harnesses`.
160pub 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    // A `[[hooks]]` entry must carry a command, and any harness it explicitly
192    // targets must be one oneharness can install a hook into — a hook that
193    // could never land is a loud error, not a silent drop.
194    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            // ids are validated above; unwrap is safe here.
200            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    // Sync settings scoped to a harness whose config file (if any) has no
212    // place for them are rejected here, at parse time: a permission rule or
213    // hook that silently would not land is worse than an error.
214    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        // Both are merged into a JSON object, so anything but a table would
234        // corrupt the harness's config file — reject the shape up front.
235        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
255/// The `source` recorded for a value that comes from an `ONEHARNESS_*`
256/// environment override rather than a config file or a built-in default. It is
257/// not a path, so it never collides with a file source or [`DEFAULT_SOURCE`].
258pub const ENV_SOURCE: &str = "environment";
259
260/// Build a config layer from the `ONEHARNESS_*` environment overrides, reading
261/// each variable through `get` (kept a parameter so this stays pure and
262/// unit-testable — the io layer passes `std::env::var`). Returns `Ok(None)` when
263/// no override is set (so no layer is added), `Ok(Some(_))` otherwise, and an
264/// `Err` for a value that cannot be parsed (a bad boolean/integer/output format,
265/// or a selection that names an unknown harness or sets both `all` and
266/// `harnesses`) — a malformed override fails loudly, exactly like a malformed
267/// file.
268///
269/// Every name mirrors its config field and CLI flag: `ONEHARNESS_<FIELD>` in
270/// upper snake case (`model` → `ONEHARNESS_MODEL`, `schema_max_retries` →
271/// `ONEHARNESS_SCHEMA_MAX_RETRIES`). List fields are comma-separated like the
272/// repeatable `--harness` / `--exclude` flags. An empty value counts as unset,
273/// matching `ONEHARNESS_CONFIG` / `ONEHARNESS_BIN_<ID>`. Only the top-level
274/// fields with a `run` flag are mapped; the sync-policy fields
275/// (`allowed_tools` / `denied_tools` / `hooks` / `settings`), the `[env]` table,
276/// and the `[harness.<id>]` overrides have no env form by design.
277pub 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    // No override touched anything: contribute no layer at all, so the report's
304    // `config_files` and provenance only mention `environment` when it matters.
305    if config == FileConfig::default() {
306        return Ok(None);
307    }
308    validate(&config)?;
309    Ok(Some(config))
310}
311
312/// Read an `ONEHARNESS_*` boolean: `true`/`false` (case-insensitive) or `1`/`0`.
313fn 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
324/// Read an `ONEHARNESS_*` value that parses via [`std::str::FromStr`] (the
325/// numeric fields), attaching the variable name to any parse failure.
326fn 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
340/// Read a comma-separated `ONEHARNESS_*` list (the selection fields), trimming
341/// each entry and dropping empties, mirroring the `--harness` / `--exclude`
342/// delimiter behavior.
343fn 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
353/// Read `ONEHARNESS_MODE`, accepting the same tokens as the `--mode` flag.
354fn 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
364/// Read `ONEHARNESS_OUTPUT_FORMAT`, accepting the same tokens as the CLI flag.
365fn 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
382/// Layer `over` (e.g. the project file) on top of `base` (e.g. the user file):
383/// a field set in `over` wins, an absent one falls through to `base`. The env
384/// tables merge key-wise (`over` wins per key); the per-harness tables merge
385/// per id and then per field. The selection (`all` + `harnesses`) moves as a
386/// unit so the layers can never combine into a contradictory selection.
387pub 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    /// The model for one harness: its `[harness.<id>]` override, else the
440    /// top-level `model`. (A CLI `--model` beats both; the caller applies it.)
441    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    /// The configured binary override for one harness, if any.
449    pub fn bin_for(&self, id: &str) -> Option<&str> {
450        self.harness.get(id).and_then(|h| h.bin.as_deref())
451    }
452
453    /// Extra args appended to one harness's command (before CLI passthrough).
454    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    /// Allow rules for one harness: its `[harness.<id>]` override, else the
462    /// top-level `allowed_tools`. (CLI `--allowed-tools` beats both.)
463    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    /// Deny rules for one harness, resolved like [`Self::allowed_tools_for`].
472    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    /// The hooks table for one harness, if configured (per-harness only:
481    /// hooks schemas are harness-specific, so there is no top-level form).
482    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    /// The normalized `[[hooks]]` that apply to harness `id`, ready to install:
487    /// entries with no `harnesses` filter or one naming `id`, with `{harness}`
488    /// substituted in the command. Order follows the config (later entries
489    /// install after earlier ones).
490    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    /// The raw settings table for one harness, if configured (per-harness
509    /// only: the shape is that harness's own config schema).
510    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    /// The configured environment for one harness, in application order:
515    /// top-level `[env]` first, then `[harness.<id>.env]` so it wins on a key
516    /// collision. (The harness's own `default_env` goes before these, and CLI
517    /// `--env` after; the runner applies env last-write-wins.)
518    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
531/// The `source` recorded for a value that comes from no file: the built-in
532/// default. File sources are paths, so the two can't collide.
533pub const DEFAULT_SOURCE: &str = "default";
534
535/// One resolved field in the `oneharness config` report: the effective value
536/// plus where it came from (a config file path, or [`DEFAULT_SOURCE`]). Both
537/// are `null` when the field is unset everywhere and has no built-in default.
538#[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/// The `oneharness config` report: the fully layered configuration with the
569/// provenance of every value, so a consumer can see exactly which file (or
570/// default) shaped each setting of a run.
571#[derive(Debug, Serialize)]
572pub struct ConfigReport {
573    pub schema_version: &'static str,
574    /// The files consulted, in layering order (user first, project last).
575    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    /// The configured `mode`, if any. Unset when only the legacy `bypass` field
583    /// (or neither) is set — the effective mode then derives from `bypass`.
584    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    /// Per-key provenance for the top-level `[env]`.
597    pub env: BTreeMap<String, Field<String>>,
598    /// Per-harness overrides, with per-field provenance.
599    pub harness: BTreeMap<String, HarnessReport>,
600}
601
602/// One harness's `[harness.<id>]` overrides, with provenance.
603#[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
615/// The last layer that sets the field wins — the same precedence [`merge`]
616/// applies — but here the winning layer's path is recorded alongside.
617fn 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
633/// Resolve the layers into a provenance report. Pure: `layers` are the parsed
634/// files in layering order (user first, project last), exactly as loaded by
635/// the io layer; built-in defaults are filled in with [`DEFAULT_SOURCE`].
636pub fn explain(layers: &[(String, FileConfig)]) -> ConfigReport {
637    // The selection moves as a unit (see `merge`): the last layer that states
638    // any selection supplies both `all` and `harnesses`, so the report can
639    // never show a contradictory mix of two files.
640    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        // Legacy `bypass` is opt-in now (the default mode is `default`), so its
714        // built-in default is false; `mode` is the richer field.
715        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        // The unfiltered entry applies everywhere with `{harness}` resolved; the
812        // filtered one only adds to codex.
813        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        // Per-harness beats top-level; others fall through to the top level.
891        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        // codex/goose/copilot have no project config file oneharness writes;
901        // opencode's permission shape is a map, not a list; only claude-code
902        // has a hooks mapping.
903        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        // The same settings parse fine where a mapping exists — including
925        // qwen deny (permissions.deny) and crush deny (options.disabled_tools),
926        // which file sync supports even though no CLI flag does.
927        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        // A scalar would be merged into the harness's JSON config as a bare
941        // value, corrupting it — the shape is validated before anything else.
942        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        // Like hooks, a project-level settings table replaces the user-level
955        // one wholesale (no deep merge across oneharness layers — the deep
956        // merge happens against the harness's own file, at sync time).
957        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        // Per-harness hooks: the project's table replaces the user's whole.
977        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        // The user file says "all"; the project names harnesses. The merged
1020        // selection must be the project's alone, not a contradictory mix.
1021        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        // And the reverse: a project `all` drops the user's harness list.
1027        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        // schema_file/schema_max_retries layer like any scalar, and `explain`
1035        // attributes each to its winning file.
1036        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        // Unset everywhere stays null (no built-in default).
1051        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        // Untouched fields fall to their defaults, attributed as such.
1106        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    /// Build a getter over a fixed set of name→value pairs, the pure stand-in
1154    /// for the process environment.
1155    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        // An empty value counts as unset, like ONEHARNESS_CONFIG / _BIN_*.
1168        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        // `history`/`history_dir` layer like any scalar; a project value beats
1213        // the user one and `explain` attributes it, defaulting `history` to false.
1214        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        // Unset everywhere: history defaults to false, dir stays null.
1230        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        // `mode` layers like any scalar; a project value beats the user one and
1263        // `explain` attributes it to the winning file. Unset stays null (the
1264        // effective mode then derives from `bypass`).
1265        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        // Unknown harness id and the all/harnesses conflict are rejected here too.
1276        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        // The env layer flows through `explain` like any other, attributed to
1289        // ENV_SOURCE, and a later env layer beats an earlier file layer.
1290        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}