Skip to main content

git_paw/
config.rs

1//! Configuration file support.
2//!
3//! Parses TOML configuration from global (`~/.config/git-paw/config.toml`)
4//! and per-repo (`.git-paw/config.toml`) files. Supports custom CLI definitions,
5//! presets, and programmatic add/remove of custom CLIs.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::PawError;
14
15/// A custom CLI definition from config.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct CustomCli {
18    /// Command or path to the CLI binary.
19    pub command: String,
20    /// Optional human-readable display name.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub display_name: Option<String>,
23    /// Optional override for the boot-prompt settle delay (milliseconds)
24    /// before the submit `Enter`.
25    ///
26    /// git-paw injects the boot block, waits this long for a paste-aware CLI
27    /// to settle the paste, then sends `Enter` separately. The default
28    /// ([`crate::DEFAULT_SUBMIT_DELAY_MS`]) suits most CLIs; raise it for a
29    /// CLI whose large-paste handling needs longer before the submit lands.
30    /// Set per-CLI rather than hardcoded so the launcher stays CLI-agnostic.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub submit_delay_ms: Option<u64>,
33    /// Optional path to this CLI's claude-format settings file
34    /// (the file carrying `allowed_bash_prefixes`).
35    ///
36    /// When set and the broker is enabled, git-paw seeds the broker-curl
37    /// allowlist into this path too, so the CLI's boot-time broker `curl`
38    /// does not raise a permission prompt. Use for claude-family variants
39    /// that read a non-default config dir (e.g. a CLI reading
40    /// `~/.claude-oss/settings.json`). A leading `~` is expanded to the
41    /// home directory. Left unset, only the repo-local `.claude/settings.json`
42    /// is seeded.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub settings_path: Option<String>,
45}
46
47/// A named preset defining branches and a CLI to use.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct Preset {
50    /// Branches to open in this preset.
51    pub branches: Vec<String>,
52    /// CLI to use for all branches in this preset.
53    pub cli: String,
54}
55
56/// Governance document paths.
57///
58/// Each field is a pointer to a user-maintained document or directory that
59/// describes some aspect of the project's governance (ADRs, test strategy,
60/// security checklist, Definition of Done, project constitution).
61///
62/// All fields are optional and stored as raw [`PathBuf`] values. Relative
63/// paths are resolved against the repository root at *use time* by
64/// downstream consumers, not at config-load time. Absolute paths are
65/// preserved as-is. No filesystem existence check is performed during
66/// config-load — pointing at a path that doesn't exist is a runtime
67/// concern, not a parse error.
68///
69/// This struct is storage-only: nothing in `git_paw::config` reads the
70/// referenced documents or enforces any rubric against them. The runtime
71/// consumer lives in the parallel `governance-context` capability.
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
73pub struct GovernanceConfig {
74    /// Directory containing ADR files. Project chooses the convention
75    /// (Nygard, MADR, `adr-tools`, custom). git-paw does not dictate one.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub adr: Option<PathBuf>,
78    /// Single Markdown file describing the project's test strategy.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub test_strategy: Option<PathBuf>,
81    /// Single Markdown file containing the project's security checklist.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub security: Option<PathBuf>,
84    /// Single Markdown file containing the project's Definition of Done.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub dod: Option<PathBuf>,
87    /// Single Markdown file containing the project's constitution
88    /// (`Spec Kit`'s `constitution.md` or any project's equivalent). May
89    /// be auto-populated from `.specify/memory/constitution.md` when the
90    /// `SpecKit` backend is active and the user has not set this field
91    /// explicitly.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub constitution: Option<PathBuf>,
94    /// Path to the repository README (e.g. `README.md`). Bring-your-own
95    /// pointer surfaced by the MCP documentation tools; `None` by default,
96    /// degrading the `get_readme` tool to a null result.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub readme: Option<PathBuf>,
99    /// Path to the documentation root directory (e.g. `docs/src`).
100    /// Bring-your-own pointer surfaced by the MCP documentation tools
101    /// (`list_docs`/`get_doc`); `None` by default, degrading those tools to
102    /// empty results.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub docs: Option<PathBuf>,
105}
106
107/// MCP server configuration.
108///
109/// Carries settings specific to the `git paw mcp` server. Currently a single
110/// optional `name` field that overrides the identity the server advertises in
111/// the `initialize` handshake's `serverInfo.name`.
112///
113/// Embedded as a plain (non-`Option`) field on [`PawConfig`] with
114/// `#[serde(default)]`, so a config with no `[mcp]` section loads
115/// [`McpConfig::default`] (`name: None`) and pre-existing configs round-trip
116/// identically.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118pub struct McpConfig {
119    /// Per-repo override for the MCP server's advertised identity
120    /// (`serverInfo.name`). When `Some`, the server advertises this name in
121    /// the `initialize` handshake; when `None` (the default), it advertises
122    /// `"git-paw"`. This is independent of the client-side `mcpServers` key the
123    /// user controls in their MCP client config — it lets multi-repo setups
124    /// distinguish instances by the server's own identity.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub name: Option<String>,
127}
128
129/// Spec scanning configuration.
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
131pub struct SpecsConfig {
132    /// Directory containing spec files (relative to repo root).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub dir: Option<String>,
135    /// Spec format type: `"openspec"` or `"markdown"`.
136    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
137    pub spec_type: Option<String>,
138}
139
140/// Enforcement mode for the opsx role-gating guard.
141///
142/// Governs how the broker reacts when a non-supervisor agent commits an
143/// `OpenSpec` archive operation (see the `opsx-role-gating` capability). The
144/// serde wire values are the lowercase strings `"warn"`, `"block"`, and
145/// `"off"`; an absent `[opsx].role_gating` resolves to [`Self::Warn`].
146#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "lowercase")]
148pub enum RoleGatingMode {
149    /// Publish an `agent.feedback` to the offending agent and record an
150    /// `agent.learning` with category `permission_pattern`. The default.
151    #[default]
152    Warn,
153    /// Warn behaviour PLUS publish an `agent.feedback` targeted at the
154    /// supervisor requesting it revert the offending commit via its
155    /// merge-orchestration skill.
156    Block,
157    /// Disable the guard entirely — no classification, feedback, or learning.
158    Off,
159}
160
161/// opsx (`OpenSpec`) integration configuration.
162///
163/// Currently carries the single `role_gating` knob. Embedded as
164/// `Option<OpsxConfig>` on [`PawConfig`] so configs without an `[opsx]`
165/// section round-trip identically.
166#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
167pub struct OpsxConfig {
168    /// Enforcement mode for the role-gating guard. `None` (the absent
169    /// default) resolves to [`RoleGatingMode::Warn`] via
170    /// [`OpsxConfig::role_gating_mode`].
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub role_gating: Option<RoleGatingMode>,
173}
174
175impl OpsxConfig {
176    /// Resolves the effective role-gating mode, defaulting to
177    /// [`RoleGatingMode::Warn`] when the field is absent.
178    #[must_use]
179    pub fn role_gating_mode(&self) -> RoleGatingMode {
180        self.role_gating.unwrap_or_default()
181    }
182}
183
184/// Session logging configuration.
185#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
186pub struct LoggingConfig {
187    /// Whether session logging is enabled.
188    #[serde(default)]
189    pub enabled: bool,
190}
191
192/// Approval level governing how much autonomy an agent has when operating
193/// on the repository.
194///
195/// The variants are ordered from most conservative to most permissive:
196///
197/// - `Manual` — the agent must ask the user to approve every file write or
198///   shell command. Safest, but slowest.
199/// - `Auto` — the agent may perform routine edits without asking, but still
200///   defers for destructive or privileged operations. This is the default.
201/// - `FullAuto` — the agent is granted full unattended permissions,
202///   bypassing per-action approval. Only appropriate for trusted sandboxes.
203#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
204#[serde(rename_all = "kebab-case")]
205pub enum ApprovalLevel {
206    /// Prompt the user for every write or command.
207    Manual,
208    /// Allow routine edits without prompting, defer for destructive ops.
209    #[default]
210    Auto,
211    /// Grant full unattended permissions (skip approvals entirely).
212    FullAuto,
213}
214
215/// Dashboard configuration.
216#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
217pub struct DashboardConfig {
218    /// Whether to show the legacy broker messages panel in the dashboard.
219    ///
220    /// Superseded by the type-filterable "Broker log" panel
221    /// ([`DashboardConfig::broker_log`]); retained for source compatibility
222    /// with v0.5.0 configs.
223    #[serde(default)]
224    pub show_message_log: bool,
225    /// Configuration for the v0.6.0 "Broker log" panel — its ring-buffer cap
226    /// and default visibility. An absent `[dashboard.broker_log]` section
227    /// loads [`BrokerLogConfig::default`] so v0.5.0 configs parse unchanged.
228    #[serde(default)]
229    pub broker_log: BrokerLogConfig,
230}
231
232/// Configuration for the dashboard's "Broker log" panel.
233///
234/// All fields carry `#[serde(default)]` so a v0.5.0 `[dashboard]` section
235/// with no `broker_log` table — or a `[dashboard.broker_log]` table that
236/// sets only some fields — loads with the documented defaults for the rest.
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238pub struct BrokerLogConfig {
239    /// Maximum number of messages retained in the panel's in-memory ring
240    /// buffer. Older messages drop off the top as new ones arrive. Default:
241    /// `500`.
242    #[serde(default = "BrokerLogConfig::default_max_messages")]
243    pub max_messages: usize,
244    /// Whether the panel is visible when the dashboard first launches. The
245    /// `l` hotkey toggles visibility at runtime regardless of this value.
246    /// Default: `true`.
247    #[serde(default = "BrokerLogConfig::default_visible")]
248    pub default_visible: bool,
249    /// Number of terminal rows the panel occupies when visible. Raised from
250    /// the v0.6.0 fixed `12` so more broker messages are visible without
251    /// scrolling; the agent table keeps a positive minimum and yields slack
252    /// to the panel only on tall terminals. Default: `20`.
253    #[serde(default = "BrokerLogConfig::default_height_lines")]
254    pub height_lines: u16,
255}
256
257impl Default for BrokerLogConfig {
258    fn default() -> Self {
259        Self {
260            max_messages: Self::default_max_messages(),
261            default_visible: Self::default_visible(),
262            height_lines: Self::default_height_lines(),
263        }
264    }
265}
266
267impl BrokerLogConfig {
268    fn default_max_messages() -> usize {
269        500
270    }
271
272    fn default_visible() -> bool {
273        true
274    }
275
276    /// Default panel height in terminal rows. Strictly greater than the
277    /// v0.6.0 fixed `12` so the panel shows materially more messages.
278    fn default_height_lines() -> u16 {
279        20
280    }
281}
282
283/// Supervisor mode configuration.
284///
285/// Supervisor mode puts git-paw in front of the agent CLI as a coordinating
286/// layer that can enforce approval policy and run a verification command
287/// after each agent completes a task.
288#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
289pub struct SupervisorConfig {
290    /// Whether supervisor mode is enabled by default for this repo.
291    #[serde(default)]
292    pub enabled: bool,
293    /// Override the CLI used when launching the supervisor (e.g. `"claude"`).
294    /// `None` resolves to the normal CLI selection flow at runtime.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub cli: Option<String>,
297    /// Test command to run after each agent completes (e.g. `"just check"`).
298    /// `None` skips the verification step.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub test_command: Option<String>,
301    /// Pre-stage lint invocation for the five-gate verification workflow.
302    ///
303    /// Drives gate 1's lint sub-step. Example values per common stack:
304    /// `"cargo clippy -- -D warnings"` (Rust), `"npm run lint"` (Node),
305    /// `"ruff check ."` (Python), `"golangci-lint run"` (Go). When `None`,
306    /// the supervisor skill renders the placeholder as `(not configured)`
307    /// and the supervisor agent skips the tooling invocation.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub lint_command: Option<String>,
310    /// Compile-step command when build is distinct from test.
311    ///
312    /// Drives gate 1's compile sub-step. Example values: `"cargo build"`
313    /// (Rust), `"npm run build"` (Node), `"mvn package"` (Java), `"go
314    /// build ./..."` (Go). When `None`, the supervisor skill renders the
315    /// placeholder as `(not configured)` and the supervisor agent skips
316    /// the tooling invocation.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub build_command: Option<String>,
319    /// Documentation-build command for gate 4 (doc audit).
320    ///
321    /// Example values: `"mdbook build docs/"` (`mdBook`), `"sphinx-build"`
322    /// (Sphinx), `"mkdocs build"` (`MkDocs`), `"npx typedoc"` (`TypeDoc`).
323    /// When `None`, the supervisor skill renders the placeholder as
324    /// `(not configured)` and the supervisor agent skips the tooling
325    /// invocation; the manual doc-surface review still applies.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub doc_build_command: Option<String>,
328    /// API-doc generator command used during spec audit.
329    ///
330    /// Distinct from [`Self::doc_build_command`] (which builds the
331    /// human-readable doc site): this one runs the per-language API-doc
332    /// extractor against changed public items. Example values:
333    /// `"cargo doc --no-deps"` (Rust), `"sphinx-build -W docs docs/_build"`
334    /// (Python/Sphinx), `"npx typedoc"` (TypeScript), `"javadoc"` (Java),
335    /// `"go doc"` (Go). When `None`, the supervisor skill renders the
336    /// `{{DOC_TOOL_COMMAND}}` placeholder as an empty string and the
337    /// surrounding prose is authored to read naturally without it.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub doc_tool_command: Option<String>,
340    /// Spec-validator command for gate 3 (spec audit).
341    ///
342    /// Typically takes a change name as argument; the supervisor agent
343    /// substitutes `{{CHANGE_ID}}` at verification time using the change
344    /// it is currently auditing. Example values: `"openspec validate
345    /// {{CHANGE_ID}} --strict"` (`OpenSpec`). When `None`, the supervisor
346    /// skill renders the placeholder as `(not configured)` and the
347    /// supervisor agent skips the tooling invocation; the manual
348    /// scenario-coverage check still applies.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub spec_validate_command: Option<String>,
351    /// Formatter-check command for gate 1's pre-stage.
352    ///
353    /// Example values: `"cargo fmt --check"` (Rust), `"prettier --check
354    /// ."` (Node), `"gofmt -l ."` (Go), `"black --check ."` (Python).
355    /// When `None`, the supervisor skill renders the placeholder as
356    /// `(not configured)` and the supervisor agent skips the tooling
357    /// invocation.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub fmt_check_command: Option<String>,
360    /// Security-audit tooling for gate 5.
361    ///
362    /// Example values: `"cargo audit"` (Rust), `"npm audit"` (Node),
363    /// `"bandit -r ."` (Python), `"gosec ./..."` (Go). When `None`, the
364    /// supervisor skill renders the placeholder as `(not configured)`
365    /// and the supervisor agent skips the tooling invocation; the manual
366    /// OWASP-category diff review still applies.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub security_audit_command: Option<String>,
369    /// Approval policy applied to agent actions.
370    #[serde(default)]
371    pub agent_approval: ApprovalLevel,
372    /// Auto-approval configuration for safe permission prompts.
373    ///
374    /// When present, the supervisor automatically approves stalled agents
375    /// whose pending command matches an entry in the safe-command whitelist.
376    /// See [`AutoApproveConfig`] for the per-field semantics.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub auto_approve: Option<AutoApproveConfig>,
379    /// Conflict detector configuration.
380    ///
381    /// Drives the broker-internal subsystem that auto-emits
382    /// `agent.feedback` and `agent.question` for forward, in-flight, and
383    /// ownership conflicts between agents. Active only when
384    /// [`SupervisorConfig::enabled`] is `true`; otherwise the detector
385    /// subsystem is not started and no auto-warnings fire.
386    #[serde(default)]
387    pub conflict: ConflictConfig,
388    /// Opt-in flag for the learnings aggregator subsystem (learnings-mode).
389    ///
390    /// When `true` (and `[broker] enabled = true`), the broker starts a
391    /// learnings aggregator that observes the session and appends
392    /// human-readable summaries to `.git-paw/session-learnings.md`. Defaults
393    /// to `false` — pre-v0.5 configs load without producing learnings.
394    #[serde(default)]
395    pub learnings: bool,
396    /// Tuning knobs for the learnings aggregator.
397    ///
398    /// Honoured only when [`Self::learnings`] is `true`. Missing fields fall
399    /// back to [`LearningsConfig::default`]. The TOML table key is
400    /// `[supervisor.learnings_config]` to avoid colliding with the boolean
401    /// `learnings` field.
402    #[serde(default)]
403    pub learnings_config: LearningsConfig,
404    /// Common dev-command allowlist configuration.
405    ///
406    /// Controls whether the supervisor seeds a curated preset of
407    /// dev-loop prefix patterns (`cargo build`, `git commit`, ...) into
408    /// `.claude/settings.json::allowed_bash_prefixes` on session start.
409    /// See [`CommonDevAllowlistConfig`] for field semantics.
410    #[serde(default)]
411    pub common_dev_allowlist: CommonDevAllowlistConfig,
412    /// Whether the broker emits a `supervisor.verify-now` nudge to the
413    /// supervisor inbox when an agent publishes an
414    /// `agent.artifact { status: "committed" }`.
415    ///
416    /// The nudge makes per-commit verification fire on an explicit event
417    /// rather than relying on the supervisor's sweep cadence to notice the
418    /// commit, so each agent's commit is verified promptly instead of being
419    /// batched with a slower agent's. `None` (the field omitted from config)
420    /// resolves to `true`; set `verify_on_commit_nudge = false` to suppress
421    /// the nudge and fall back to sweep-cadence verification. Resolve the
422    /// effective value with [`Self::verify_on_commit_nudge_enabled`].
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub verify_on_commit_nudge: Option<bool>,
425    /// Whether the per-worktree pre-commit branch guard refuses commits that
426    /// would advance a branch other than the worktree's assigned branch.
427    ///
428    /// `None` (the default) resolves to `true` via [`Self::strict_branch_guard`]
429    /// — the guard is on unless explicitly disabled. Set
430    /// `[supervisor] strict_branch_guard = false` to opt out of *enforcement*
431    /// (the post-commit `agent.feedback` detection still fires; detection
432    /// without enforcement). Guards against cross-worktree contamination where
433    /// a commit advances the wrong branch because linked worktrees share
434    /// `.git/refs`.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub strict_branch_guard: Option<bool>,
437    /// Whether the supervisor reverts an opsx role-gating violation commit
438    /// without first confirming with the user.
439    ///
440    /// Consumed by the supervisor skill's merge-orchestration revert flow: in
441    /// `block` mode the guard publishes a revert-request `agent.feedback` to
442    /// the supervisor, and the supervisor confirms with the user before
443    /// running `git revert` UNLESS this is `true`. `None` (the default)
444    /// resolves to `false` via [`Self::auto_revert`] — confirmation is
445    /// required by default so a destructive revert never fires unattended.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub auto_revert: Option<bool>,
448    /// Whether manual (user-decided) approval patterns are recorded to the
449    /// per-session log at `.git-paw/sessions/<session>.manual-approvals.jsonl`
450    /// and surfaced via `git paw approvals`.
451    ///
452    /// `None` (the field omitted from config) resolves to `true` via
453    /// [`Self::manual_approvals_log_enabled`] — recording is on unless
454    /// explicitly disabled. Set `[supervisor] manual_approvals_log = false` to
455    /// suppress both the log writes AND the derived `permission_pattern`
456    /// learnings emission. The opt-out affects writes only; `git paw approvals`
457    /// still reads any pre-existing log. See the `approval-pattern-surfacing`
458    /// change.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub manual_approvals_log: Option<bool>,
461    /// Configuration for the `/tell` user→agent routing command.
462    ///
463    /// Carries the default delivery mode and the inventory-cache max age. The
464    /// TOML table key is `[supervisor.tell]`. An absent table — every v0.5.0
465    /// config — loads [`TellConfig::default`] (mode `feedback`, max age 60s)
466    /// and round-trips identically because [`TellConfig::is_default`] skips
467    /// serialising the all-default table.
468    #[serde(default, skip_serializing_if = "TellConfig::is_default")]
469    pub tell: TellConfig,
470}
471
472/// Delivery mode for the supervisor `/tell` routing command.
473///
474/// Selects the default channel by which a user-typed prompt reaches the named
475/// agent. The serde wire values are the kebab-case strings `"feedback"` and
476/// `"send-keys"`; an absent `[supervisor.tell] mode` resolves to
477/// [`Self::Feedback`].
478#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "kebab-case")]
480pub enum TellMode {
481    /// Queue an `agent.feedback` broker message — the agent consumes it on its
482    /// next inbox poll. Safe by default: the prompt is recorded, not race-y.
483    #[default]
484    Feedback,
485    /// Inject the prompt directly into the target pane via `tmux send-keys`.
486    /// Faster, but only safe for agents in accept-edits mode; `/tell` falls
487    /// back to [`Self::Feedback`] when the target's detected mode is not
488    /// `accept-edits`.
489    SendKeys,
490}
491
492/// Configuration for the supervisor `/tell` user→agent routing command.
493///
494/// Embedded as a plain (non-`Option`) field on [`SupervisorConfig`] with
495/// `#[serde(default)]`, so a `[supervisor]` section with no `[supervisor.tell]`
496/// table loads the documented defaults.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub struct TellConfig {
499    /// Default delivery mode for `/tell`. Default: [`TellMode::Feedback`].
500    #[serde(default)]
501    pub mode: TellMode,
502    /// Maximum age (seconds) of the cached inventory snapshot before
503    /// `/tell` / `/agents` rebuild it on demand. Default: `60`.
504    #[serde(default = "TellConfig::default_inventory_max_age_seconds")]
505    pub inventory_max_age_seconds: u64,
506}
507
508impl Default for TellConfig {
509    fn default() -> Self {
510        Self {
511            mode: TellMode::default(),
512            inventory_max_age_seconds: Self::default_inventory_max_age_seconds(),
513        }
514    }
515}
516
517impl TellConfig {
518    fn default_inventory_max_age_seconds() -> u64 {
519        60
520    }
521
522    /// Returns `true` when this config equals [`TellConfig::default`].
523    ///
524    /// Used as the `skip_serializing_if` predicate so an all-default
525    /// `[supervisor.tell]` table is omitted on save, keeping v0.5.0 configs
526    /// byte-stable round-trips.
527    #[must_use]
528    pub fn is_default(&self) -> bool {
529        *self == Self::default()
530    }
531}
532
533impl SupervisorConfig {
534    /// Resolves whether the pre-commit branch guard enforces (blocks) on a
535    /// branch mismatch. Defaults to `true` when the config field is absent.
536    #[must_use]
537    pub fn strict_branch_guard(&self) -> bool {
538        self.strict_branch_guard.unwrap_or(true)
539    }
540
541    /// Resolves whether the supervisor reverts an opsx role-gating violation
542    /// commit without user confirmation. Defaults to `false` when the config
543    /// field is absent — a revert always asks first unless explicitly opted in.
544    #[must_use]
545    pub fn auto_revert(&self) -> bool {
546        self.auto_revert.unwrap_or(false)
547    }
548
549    /// Resolves whether manual-approval pattern recording is enabled.
550    ///
551    /// Returns the configured [`Self::manual_approvals_log`] value, or `true`
552    /// when the field is unset — recording is on by default.
553    #[must_use]
554    pub fn manual_approvals_log_enabled(&self) -> bool {
555        self.manual_approvals_log.unwrap_or(true)
556    }
557
558    /// Borrowed view of the seven gate-command templates suitable for
559    /// passing to [`crate::skills::render`]. Each field maps directly to
560    /// the matching `Option<String>` on this struct.
561    #[must_use]
562    pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
563        crate::skills::GateCommands {
564            test_command: self.test_command.as_deref(),
565            lint_command: self.lint_command.as_deref(),
566            build_command: self.build_command.as_deref(),
567            doc_build_command: self.doc_build_command.as_deref(),
568            spec_validate_command: self.spec_validate_command.as_deref(),
569            fmt_check_command: self.fmt_check_command.as_deref(),
570            security_audit_command: self.security_audit_command.as_deref(),
571            doc_tool_command: self.doc_tool_command.as_deref(),
572        }
573    }
574
575    /// Resolves whether the broker should emit a `supervisor.verify-now`
576    /// nudge on each committed artifact.
577    ///
578    /// Returns the configured [`Self::verify_on_commit_nudge`] value, or
579    /// `true` when the field is unset — per-commit verification nudging is on
580    /// by default.
581    #[must_use]
582    pub fn verify_on_commit_nudge_enabled(&self) -> bool {
583        self.verify_on_commit_nudge.unwrap_or(true)
584    }
585}
586
587/// Configuration for the common dev-command allowlist preset.
588///
589/// The universal preset is a curated set of stack-neutral, repeatedly-
590/// prompted dev-loop commands (non-destructive git verbs plus read-only
591/// `find` / `grep` / `sed -n`) that the supervisor seeds into Claude's
592/// `allowed_bash_prefixes` so agents do not hit a permission prompt for
593/// each variant of these commands. Stack-specific grants are opt-in via
594/// `stacks` (named presets `rust` / `node` / `python` / `go`) and/or
595/// the free-form `extra` list. See `src/supervisor/dev_allowlist.rs`
596/// for the preset constants and the merge implementation.
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598pub struct CommonDevAllowlistConfig {
599    /// Whether the dev-allowlist seeder runs on supervisor start.
600    ///
601    /// Defaults to `true` — the v0.5.0 dogfood evidence makes the
602    /// feature most useful when on by default. Opt out with
603    /// `[supervisor.common_dev_allowlist] enabled = false`.
604    #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
605    pub enabled: bool,
606    /// Named, curated stack presets the repository opts into.
607    ///
608    /// Each entry names a built-in stack preset (`rust` / `node` /
609    /// `python` / `go`) whose curated prefix bundle is seeded in
610    /// addition to the universal preset. Unknown names contribute
611    /// nothing. Defaults to empty — a fresh repo seeds only the
612    /// universal preset, never a toolchain it does not use. See
613    /// `src/supervisor/dev_allowlist.rs::stack_preset`.
614    #[serde(default)]
615    pub stacks: Vec<String>,
616    /// Additional project-specific prefix patterns appended to the
617    /// built-in preset (and to any selected stack presets).
618    ///
619    /// Each entry is a raw string consumed by Claude's prefix matcher;
620    /// the seeder does not validate the strings. Duplicates of preset
621    /// or stack entries are silently de-duplicated.
622    #[serde(default)]
623    pub extra: Vec<String>,
624}
625
626impl Default for CommonDevAllowlistConfig {
627    fn default() -> Self {
628        Self {
629            enabled: Self::default_enabled(),
630            stacks: Vec::new(),
631            extra: Vec::new(),
632        }
633    }
634}
635
636impl CommonDevAllowlistConfig {
637    fn default_enabled() -> bool {
638        true
639    }
640}
641
642/// Tuning knobs for the learnings aggregator.
643///
644/// The aggregator periodically flushes accumulated learnings to
645/// `.git-paw/session-learnings.md` plus one final flush at broker shutdown.
646/// `flush_interval_seconds` controls the periodic cadence; bursts of activity
647/// may flush sooner if the in-memory queue grows past the soft cap.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649pub struct LearningsConfig {
650    /// Interval between periodic flushes to disk. Default: `60`.
651    #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
652    pub flush_interval_seconds: u64,
653    /// Whether flushed learnings are also published to the broker as
654    /// `agent.learning` messages (in addition to the markdown file).
655    ///
656    /// Default [`BrokerPublish::Auto`] follows `[broker] enabled`: publish
657    /// when the broker is running, file-only when it is not. Set to
658    /// [`BrokerPublish::ForceOff`] to keep file-only output even with an
659    /// active broker. See the `agent-learning-variant` change.
660    #[serde(default)]
661    pub broker_publish: BrokerPublish,
662}
663
664impl Default for LearningsConfig {
665    fn default() -> Self {
666        Self {
667            flush_interval_seconds: Self::default_flush_interval_seconds(),
668            broker_publish: BrokerPublish::default(),
669        }
670    }
671}
672
673impl LearningsConfig {
674    fn default_flush_interval_seconds() -> u64 {
675        60
676    }
677}
678
679/// Whether the learnings aggregator publishes flushed records to the broker.
680///
681/// The markdown file output (`.git-paw/session-learnings.md`) is unconditional
682/// — this knob only governs the additional `agent.learning` broker publish.
683#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
684#[serde(rename_all = "snake_case")]
685pub enum BrokerPublish {
686    /// Follow `[broker] enabled`: publish to the broker when it is running,
687    /// file-only when it is not. This is the default.
688    #[default]
689    Auto,
690    /// Never publish to the broker, even when it is running (file-only).
691    ForceOff,
692}
693
694impl BrokerPublish {
695    /// Resolves the effective publish decision against whether the broker is
696    /// enabled for this session.
697    #[must_use]
698    pub fn resolve(self, broker_enabled: bool) -> bool {
699        match self {
700            Self::Auto => broker_enabled,
701            Self::ForceOff => false,
702        }
703    }
704}
705
706/// Configuration for the broker-internal conflict detector.
707///
708/// The detector observes `agent.intent` and `agent.status` events as they
709/// pass through the publish pipeline and emits `agent.feedback` /
710/// `agent.question` when one of three failure shapes triggers (forward,
711/// in-flight, ownership). All fields have defaults; an entirely absent
712/// `[supervisor.conflict]` section loads [`ConflictConfig::default`].
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub struct ConflictConfig {
715    /// Window after which an unresolved in-flight conflict escalates to
716    /// the supervisor inbox via `agent.question`. Default: `120`.
717    #[serde(default = "ConflictConfig::default_window_seconds")]
718    pub window_seconds: u64,
719    /// Master switch for forward-conflict warnings. When `false`, no
720    /// `agent.feedback` is emitted for overlapping `agent.intent`
721    /// declarations, but the tracker SHALL still record intents (so
722    /// in-flight and ownership detection remain functional). Default:
723    /// `true`.
724    #[serde(default = "ConflictConfig::default_true")]
725    pub warn_on_intent_overlap: bool,
726    /// Whether ownership violations escalate to the supervisor inbox via
727    /// `agent.question`. The violator-bound `agent.feedback` always fires
728    /// regardless of this flag — only the supervisor follow-up is gated.
729    /// Default: `true`.
730    #[serde(default = "ConflictConfig::default_true")]
731    pub escalate_on_violation: bool,
732}
733
734impl Default for ConflictConfig {
735    fn default() -> Self {
736        Self {
737            window_seconds: Self::default_window_seconds(),
738            warn_on_intent_overlap: true,
739            escalate_on_violation: true,
740        }
741    }
742}
743
744impl ConflictConfig {
745    fn default_window_seconds() -> u64 {
746        120
747    }
748
749    fn default_true() -> bool {
750        true
751    }
752}
753
754/// Coarse-grained policy preset that maps onto a known [`AutoApproveConfig`]
755/// shape.
756///
757/// The presets exist so users do not have to hand-craft a whitelist when
758/// they just want a sensible default for the project. The mapping is:
759///
760/// - `Off` — auto-approval is disabled regardless of other fields.
761/// - `Conservative` — auto-approve `cargo`/`git commit` style commands but
762///   strip `git push` and `curl` from the effective whitelist.
763/// - `Safe` — the built-in default; auto-approve everything in
764///   [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
765#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
766#[serde(rename_all = "kebab-case")]
767pub enum ApprovalLevelPreset {
768    /// Disable auto-approval entirely.
769    Off,
770    /// Approve only the most uncontroversial commands (no push/curl).
771    Conservative,
772    /// Approve every entry in the built-in safe-command list.
773    #[default]
774    Safe,
775}
776
777/// Configuration for the supervisor auto-approval feature.
778///
779/// Auto-approval detects permission prompts in stalled agent panes via
780/// `tmux capture-pane`, classifies the pending command, and dispatches the
781/// `BTab Down Enter` keystroke sequence when the command matches the
782/// whitelist.
783///
784/// Embedded as `Option<AutoApproveConfig>` on [`SupervisorConfig`] so
785/// existing configs without an `[supervisor.auto_approve]` table continue
786/// to round-trip identically.
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788pub struct AutoApproveConfig {
789    /// Master enable flag. When `false`, no detection or approval runs.
790    #[serde(default = "AutoApproveConfig::default_enabled")]
791    pub enabled: bool,
792    /// Project-specific safe-command prefixes appended to the built-in
793    /// defaults from
794    /// [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
795    #[serde(default)]
796    pub safe_commands: Vec<String>,
797    /// Threshold (in seconds) of `last_seen` staleness before an agent in
798    /// `working` status is treated as stalled by the poll loop.
799    #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
800    pub stall_threshold_seconds: u64,
801    /// Coarse policy preset applied on top of the explicit fields.
802    ///
803    /// When the preset is `Off`, [`Self::enabled`] is forced to `false` by
804    /// [`Self::resolved`]. When the preset is `Conservative`, the effective
805    /// whitelist is the built-in defaults minus `git push` and `curl`
806    /// entries.
807    #[serde(default)]
808    pub approval_level: ApprovalLevelPreset,
809    /// Whether filesystem write / edit / create prompts whose target path
810    /// resolves *inside* the agent's own worktree are auto-approved.
811    ///
812    /// `None` (the absent default) resolves to `true` via
813    /// [`Self::approve_worktree_writes`] — worktrees are isolated, so
814    /// confining auto-approval to the worktree boundary is safe by
815    /// construction. Set to `false` to revert to the manual-prompt flow for
816    /// all file operations. Out-of-worktree paths always require manual
817    /// approval regardless of this flag.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub approve_worktree_writes: Option<bool>,
820}
821
822impl Default for AutoApproveConfig {
823    fn default() -> Self {
824        Self {
825            enabled: Self::default_enabled(),
826            safe_commands: Vec::new(),
827            stall_threshold_seconds: Self::default_stall_threshold_seconds(),
828            approval_level: ApprovalLevelPreset::Safe,
829            approve_worktree_writes: None,
830        }
831    }
832}
833
834impl AutoApproveConfig {
835    /// Minimum stall threshold in seconds. Anything lower is clamped to
836    /// avoid pathological poll loops.
837    pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
838
839    fn default_enabled() -> bool {
840        true
841    }
842
843    fn default_stall_threshold_seconds() -> u64 {
844        30
845    }
846
847    /// Returns a copy of this config with preset rules applied and the
848    /// stall threshold floor enforced.
849    ///
850    /// - When `approval_level == Off`, `enabled` is forced to `false`.
851    /// - When `stall_threshold_seconds < MIN_STALL_THRESHOLD_SECONDS`, the
852    ///   value is clamped and a warning is written to stderr.
853    #[must_use]
854    pub fn resolved(&self) -> Self {
855        let mut out = self.clone();
856        if out.approval_level == ApprovalLevelPreset::Off {
857            out.enabled = false;
858        }
859        if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
860            eprintln!(
861                "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
862                out.stall_threshold_seconds,
863                Self::MIN_STALL_THRESHOLD_SECONDS
864            );
865            out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
866        }
867        out
868    }
869
870    /// Returns whether worktree-confined file operations are auto-approved.
871    ///
872    /// Resolves the optional [`Self::approve_worktree_writes`] field to its
873    /// effective boolean: an absent value (the common case — no
874    /// `[supervisor.auto_approve]` section, or the field omitted) defaults to
875    /// `true`.
876    #[must_use]
877    pub fn approve_worktree_writes(&self) -> bool {
878        self.approve_worktree_writes.unwrap_or(true)
879    }
880
881    /// Returns the effective whitelist for this config, applying the preset
882    /// to the union of built-in defaults and user-configured `safe_commands`.
883    ///
884    /// - `Off` and `Safe` both return defaults plus configured extras.
885    /// - `Conservative` returns the same union with `git push` and any
886    ///   `curl` entries filtered out.
887    #[must_use]
888    pub fn effective_whitelist(&self) -> Vec<String> {
889        let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
890            .iter()
891            .map(|s| (*s).to_string())
892            .collect();
893        for extra in &self.safe_commands {
894            if !out.iter().any(|e| e == extra) {
895                out.push(extra.clone());
896            }
897        }
898        if self.approval_level == ApprovalLevelPreset::Conservative {
899            out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
900        }
901        out
902    }
903}
904
905/// Returns the CLI-specific permission flag for `cli` at the given approval
906/// `level`, or an empty string if the combination has no mapped flag.
907///
908/// # Examples
909///
910/// ```
911/// use git_paw::config::{approval_flags, ApprovalLevel};
912///
913/// assert_eq!(
914///     approval_flags("claude", &ApprovalLevel::FullAuto),
915///     "--dangerously-skip-permissions",
916/// );
917/// assert_eq!(
918///     approval_flags("codex", &ApprovalLevel::Auto),
919///     "--approval-mode=auto-edit",
920/// );
921/// assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
922/// assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
923/// ```
924#[must_use]
925pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
926    match (cli, level) {
927        ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
928        ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
929        ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
930        _ => "",
931    }
932}
933
934/// Configuration for the broker filesystem watcher.
935///
936/// The watcher publishes `agent.status: working` from git-status changes.
937/// Bug 8 (`auto-approve-scope-v0-6-x`) adds a post-commit re-entry: after an
938/// `agent.artifact status: "committed"` event, a subsequent file modification
939/// observed within [`Self::republish_working_ttl_seconds`] re-publishes
940/// `working` so the dashboard reflects the agent's continued activity.
941#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
942pub struct WatcherConfig {
943    /// TTL (seconds) after a `committed` event during which a file write
944    /// re-publishes `working`.
945    ///
946    /// `None` resolves to [`Self::DEFAULT_REPUBLISH_TTL_SECONDS`] (60) via
947    /// [`Self::republish_working_ttl_seconds`]. A value of `0` disables the
948    /// auto-republish entirely (restoring the v0.5.0 "committed is terminal
949    /// until explicit republish" model). Non-zero values below
950    /// [`Self::MIN_REPUBLISH_TTL_SECONDS`] (5) are clamped to that floor with
951    /// a stderr warning.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub republish_working_ttl_seconds: Option<u64>,
954}
955
956impl WatcherConfig {
957    /// Default post-commit re-entry TTL in seconds.
958    pub const DEFAULT_REPUBLISH_TTL_SECONDS: u64 = 60;
959    /// Minimum non-zero TTL; smaller positive values clamp up to this floor.
960    pub const MIN_REPUBLISH_TTL_SECONDS: u64 = 5;
961
962    /// Returns the effective post-commit re-entry TTL in seconds.
963    ///
964    /// - `None` → [`Self::DEFAULT_REPUBLISH_TTL_SECONDS`].
965    /// - `Some(0)` → `0` (auto-republish disabled).
966    /// - `Some(n)` with `0 < n < 5` → clamped to
967    ///   [`Self::MIN_REPUBLISH_TTL_SECONDS`] with a stderr warning.
968    /// - `Some(n)` with `n >= 5` → `n`.
969    #[must_use]
970    pub fn republish_working_ttl_seconds(&self) -> u64 {
971        match self.republish_working_ttl_seconds {
972            None => Self::DEFAULT_REPUBLISH_TTL_SECONDS,
973            Some(0) => 0,
974            Some(n) if n < Self::MIN_REPUBLISH_TTL_SECONDS => {
975                eprintln!(
976                    "warning: [broker.watcher] republish_working_ttl_seconds = {n} clamped to {}s minimum",
977                    Self::MIN_REPUBLISH_TTL_SECONDS
978                );
979                Self::MIN_REPUBLISH_TTL_SECONDS
980            }
981            Some(n) => n,
982        }
983    }
984}
985
986/// HTTP broker configuration for agent coordination.
987#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
988pub struct BrokerConfig {
989    /// Whether the broker is enabled.
990    #[serde(default)]
991    pub enabled: bool,
992    /// TCP port the broker listens on.
993    #[serde(default = "BrokerConfig::default_port")]
994    pub port: u16,
995    /// Bind address for the broker.
996    #[serde(default = "BrokerConfig::default_bind")]
997    pub bind: String,
998    /// Filesystem watcher tuning.
999    #[serde(default)]
1000    pub watcher: WatcherConfig,
1001}
1002
1003impl Default for BrokerConfig {
1004    fn default() -> Self {
1005        Self {
1006            enabled: false,
1007            port: 9119,
1008            bind: "127.0.0.1".to_string(),
1009            watcher: WatcherConfig::default(),
1010        }
1011    }
1012}
1013
1014impl BrokerConfig {
1015    /// Returns the full URL for the broker endpoint.
1016    pub fn url(&self) -> String {
1017        format!("http://{}:{}", self.bind, self.port)
1018    }
1019
1020    fn default_port() -> u16 {
1021        9119
1022    }
1023
1024    fn default_bind() -> String {
1025        "127.0.0.1".to_string()
1026    }
1027}
1028
1029/// Layout configuration for git-paw-managed tmux sessions.
1030///
1031/// Controls the optional pane "affordances" — heavy borders, per-pane title
1032/// labels, and active-pane highlighting — applied to `paw-*` sessions.
1033#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1034pub struct LayoutConfig {
1035    /// Whether to apply the border affordances (heavy borders, dim/active
1036    /// border styling, per-pane label strip, and per-pane titles) to
1037    /// git-paw-managed sessions.
1038    ///
1039    /// `None` (the default, including when the `[layout]` section is absent)
1040    /// resolves to `true` via [`LayoutConfig::border_affordances_enabled`].
1041    /// Set to `false` to opt out and inherit the user's default tmux styling.
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub border_affordances: Option<bool>,
1044}
1045
1046impl LayoutConfig {
1047    /// Resolve the border-affordances setting, defaulting to `true` when unset.
1048    #[must_use]
1049    pub fn border_affordances_enabled(&self) -> bool {
1050        self.border_affordances.unwrap_or(true)
1051    }
1052}
1053
1054/// Placement of agent worktrees relative to the repository.
1055///
1056/// Selects where [`crate::git::create_worktree`] creates a worktree:
1057///
1058/// - `Sibling` — the v0.7.0 layout: `<repo_parent>/<project>-<branch-slug>`,
1059///   beside the repository in its parent directory. This is the
1060///   default-on-absent value so pre-existing configs (and sessions created
1061///   before this field existed) behave identically to v0.7.0.
1062/// - `Child` — the contained layout: `<repo_root>/.git-paw/worktrees/<branch-slug>`,
1063///   inside the repository. New repos opt into this via `git paw init`,
1064///   enabling a project-scoped permission model (one grant for
1065///   `.git-paw/worktrees/` instead of scattered sibling directories).
1066///
1067/// The serde wire values are the lowercase strings `"child"` and `"sibling"`.
1068#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1069#[serde(rename_all = "lowercase")]
1070pub enum WorktreePlacement {
1071    /// Create worktrees beside the repository at
1072    /// `<repo_parent>/<project>-<branch-slug>` (the v0.7.0 layout). The
1073    /// default when `worktree_placement` is absent.
1074    #[default]
1075    Sibling,
1076    /// Create worktrees inside the repository at
1077    /// `<repo_root>/.git-paw/worktrees/<branch-slug>`.
1078    Child,
1079}
1080
1081/// Top-level git-paw configuration.
1082///
1083/// All fields are optional — absent config files produce empty defaults.
1084#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1085pub struct PawConfig {
1086    /// Default CLI to use when none is specified.
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub default_cli: Option<String>,
1089
1090    /// Default CLI for `--from-specs` (bypasses picker when set).
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub default_spec_cli: Option<String>,
1093
1094    /// Prefix for spec-derived branch names (default: `"spec/"`).
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub branch_prefix: Option<String>,
1097
1098    /// Whether to enable tmux mouse mode for sessions.
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub mouse: Option<bool>,
1101
1102    /// Custom CLI definitions keyed by name.
1103    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1104    pub clis: HashMap<String, CustomCli>,
1105
1106    /// Named presets keyed by name.
1107    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1108    pub presets: HashMap<String, Preset>,
1109
1110    /// Spec scanning configuration.
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub specs: Option<SpecsConfig>,
1113
1114    /// Session logging configuration.
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub logging: Option<LoggingConfig>,
1117
1118    /// Dashboard configuration.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub dashboard: Option<DashboardConfig>,
1121
1122    /// HTTP broker configuration.
1123    #[serde(default)]
1124    pub broker: BrokerConfig,
1125
1126    /// Supervisor mode configuration.
1127    #[serde(default, skip_serializing_if = "Option::is_none")]
1128    pub supervisor: Option<SupervisorConfig>,
1129
1130    /// Governance document path pointers.
1131    ///
1132    /// All sub-fields are optional. Absence is equivalent to an empty
1133    /// `[governance]` section; v0.4 configs (no `[governance]` at all) load
1134    /// with `GovernanceConfig::default()` here.
1135    #[serde(default)]
1136    pub governance: GovernanceConfig,
1137
1138    /// Layout configuration for git-paw-managed tmux sessions.
1139    ///
1140    /// Absent `[layout]` (v0.5.0 and earlier configs) loads as `None`, which
1141    /// [`PawConfig::border_affordances_enabled`] resolves to the default
1142    /// (affordances on).
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub layout: Option<LayoutConfig>,
1145
1146    /// opsx (`OpenSpec`) integration configuration.
1147    ///
1148    /// Absent `[opsx]` (v0.5.0 and earlier configs) loads as `None`, which
1149    /// [`PawConfig::role_gating_mode`] resolves to the default
1150    /// ([`RoleGatingMode::Warn`]).
1151    #[serde(default, skip_serializing_if = "Option::is_none")]
1152    pub opsx: Option<OpsxConfig>,
1153
1154    /// MCP server configuration.
1155    ///
1156    /// Absent `[mcp]` (v0.6.0 and earlier configs) loads as
1157    /// [`McpConfig::default`] (`name: None`), so the MCP server advertises the
1158    /// default `"git-paw"` identity and pre-existing configs round-trip
1159    /// unchanged.
1160    #[serde(default)]
1161    pub mcp: McpConfig,
1162
1163    /// Placement of agent worktrees relative to the repository
1164    /// (`"child"` or `"sibling"`).
1165    ///
1166    /// Absent (every v0.7.0 and earlier config) resolves to
1167    /// [`WorktreePlacement::Sibling`] via [`PawConfig::worktree_placement`],
1168    /// preserving the v0.7.0 sibling layout exactly. `git paw init` writes
1169    /// `"child"` for new repos. Serialised with `skip_serializing_if` so a
1170    /// default value never appears in round-tripped configs, keeping
1171    /// pre-existing configs byte-stable.
1172    #[serde(default, skip_serializing_if = "Option::is_none")]
1173    pub worktree_placement: Option<WorktreePlacement>,
1174}
1175
1176impl PawConfig {
1177    /// Returns a new config that merges `overlay` on top of `self`.
1178    ///
1179    /// Scalar fields from `overlay` take precedence when present.
1180    /// Map fields are merged with `overlay` entries winning on key collisions.
1181    #[must_use]
1182    pub fn merged_with(&self, overlay: &Self) -> Self {
1183        let mut clis = self.clis.clone();
1184        for (k, v) in &overlay.clis {
1185            clis.insert(k.clone(), v.clone());
1186        }
1187
1188        let mut presets = self.presets.clone();
1189        for (k, v) in &overlay.presets {
1190            presets.insert(k.clone(), v.clone());
1191        }
1192
1193        Self {
1194            default_cli: overlay
1195                .default_cli
1196                .clone()
1197                .or_else(|| self.default_cli.clone()),
1198            default_spec_cli: overlay
1199                .default_spec_cli
1200                .clone()
1201                .or_else(|| self.default_spec_cli.clone()),
1202            branch_prefix: overlay
1203                .branch_prefix
1204                .clone()
1205                .or_else(|| self.branch_prefix.clone()),
1206            mouse: overlay.mouse.or(self.mouse),
1207            clis,
1208            presets,
1209            specs: overlay.specs.clone().or_else(|| self.specs.clone()),
1210            logging: overlay.logging.clone().or_else(|| self.logging.clone()),
1211            dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
1212            broker: if overlay.broker == BrokerConfig::default() {
1213                self.broker.clone()
1214            } else {
1215                overlay.broker.clone()
1216            },
1217            supervisor: overlay
1218                .supervisor
1219                .clone()
1220                .or_else(|| self.supervisor.clone()),
1221            governance: GovernanceConfig {
1222                adr: overlay
1223                    .governance
1224                    .adr
1225                    .clone()
1226                    .or_else(|| self.governance.adr.clone()),
1227                test_strategy: overlay
1228                    .governance
1229                    .test_strategy
1230                    .clone()
1231                    .or_else(|| self.governance.test_strategy.clone()),
1232                security: overlay
1233                    .governance
1234                    .security
1235                    .clone()
1236                    .or_else(|| self.governance.security.clone()),
1237                dod: overlay
1238                    .governance
1239                    .dod
1240                    .clone()
1241                    .or_else(|| self.governance.dod.clone()),
1242                constitution: overlay
1243                    .governance
1244                    .constitution
1245                    .clone()
1246                    .or_else(|| self.governance.constitution.clone()),
1247                readme: overlay
1248                    .governance
1249                    .readme
1250                    .clone()
1251                    .or_else(|| self.governance.readme.clone()),
1252                docs: overlay
1253                    .governance
1254                    .docs
1255                    .clone()
1256                    .or_else(|| self.governance.docs.clone()),
1257            },
1258            layout: overlay.layout.clone().or_else(|| self.layout.clone()),
1259            opsx: overlay.opsx.clone().or_else(|| self.opsx.clone()),
1260            mcp: McpConfig {
1261                name: overlay.mcp.name.clone().or_else(|| self.mcp.name.clone()),
1262            },
1263            worktree_placement: overlay.worktree_placement.or(self.worktree_placement),
1264        }
1265    }
1266
1267    /// Resolves the effective worktree placement for this config, defaulting
1268    /// to [`WorktreePlacement::Sibling`] when `worktree_placement` is absent.
1269    #[must_use]
1270    pub fn worktree_placement(&self) -> WorktreePlacement {
1271        self.worktree_placement.unwrap_or_default()
1272    }
1273
1274    /// Resolves the effective opsx role-gating mode for this config,
1275    /// defaulting to [`RoleGatingMode::Warn`] when `[opsx]` or its
1276    /// `role_gating` field is absent.
1277    #[must_use]
1278    pub fn role_gating_mode(&self) -> RoleGatingMode {
1279        self.opsx
1280            .as_ref()
1281            .map(OpsxConfig::role_gating_mode)
1282            .unwrap_or_default()
1283    }
1284
1285    /// Resolve whether the border affordances should be applied, defaulting to
1286    /// `true` when the `[layout]` section or its `border_affordances` field is
1287    /// absent.
1288    #[must_use]
1289    pub fn border_affordances_enabled(&self) -> bool {
1290        self.layout
1291            .as_ref()
1292            .is_none_or(LayoutConfig::border_affordances_enabled)
1293    }
1294
1295    /// Resolves the effective MCP server identity advertised in the
1296    /// `initialize` handshake's `serverInfo.name`.
1297    ///
1298    /// Returns the configured `[mcp].name` when set, otherwise the default
1299    /// `"git-paw"`.
1300    #[must_use]
1301    pub fn mcp_server_name(&self) -> String {
1302        self.mcp
1303            .name
1304            .clone()
1305            .unwrap_or_else(|| "git-paw".to_string())
1306    }
1307
1308    /// Returns a preset by name, if it exists.
1309    pub fn get_preset(&self, name: &str) -> Option<&Preset> {
1310        self.presets.get(name)
1311    }
1312
1313    /// Returns the dashboard configuration, if it exists.
1314    pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
1315        self.dashboard.as_ref()
1316    }
1317}
1318
1319/// Returns the path to the global config file (`~/.config/git-paw/config.toml`).
1320pub fn global_config_path() -> Result<PathBuf, PawError> {
1321    crate::dirs::config_dir()
1322        .map(|d| d.join("git-paw").join("config.toml"))
1323        .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
1324}
1325
1326/// Returns the path to a repo-level config file (`.git-paw/config.toml`).
1327pub fn repo_config_path(repo_root: &Path) -> PathBuf {
1328    repo_root.join(".git-paw").join("config.toml")
1329}
1330
1331/// Loads a [`PawConfig`] from a TOML file, returning `Ok(None)` if the file does not exist.
1332fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
1333    match fs::read_to_string(path) {
1334        Ok(contents) => {
1335            let config: PawConfig = toml::from_str(&contents)
1336                .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
1337            Ok(Some(config))
1338        }
1339        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1340        Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
1341    }
1342}
1343
1344/// Loads only the repo-level configuration (`.git-paw/config.toml`).
1345///
1346/// Returns defaults if the file does not exist. Useful when you need to
1347/// update and save repo-level settings without clobbering global values.
1348///
1349/// Applies post-deserialise auto-wiring for governance documents (see
1350/// [`auto_wire_governance`]).
1351pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
1352    let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1353    auto_wire_governance(&mut config, repo_root);
1354    Ok(config)
1355}
1356
1357/// Populates `config.governance.constitution` from
1358/// `git_paw::specs::speckit::detect_constitution` when:
1359///
1360/// 1. The user has not set `governance.constitution` explicitly
1361///    (i.e. it is `None` after TOML deserialisation), AND
1362/// 2. A `[specs]` section is present, AND
1363/// 3. `specs.type == "speckit"`.
1364///
1365/// Explicit user values always win — even if the explicit value points
1366/// at a path that does not exist. The check is `is_some()`, not
1367/// `is_some_and(|p| p.exists())`, so an empty-string or invalid path
1368/// still suppresses auto-wiring. This lets users disable the auto-wiring
1369/// without deleting the constitution slot.
1370///
1371/// This function is intentionally a no-op when the `SpecKit` backend
1372/// is not active. It is also a no-op when the configured `specs.dir`'s
1373/// parent does not contain `memory/constitution.md`.
1374fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
1375    if config.governance.constitution.is_some() {
1376        return;
1377    }
1378    let Some(specs_cfg) = config.specs.as_ref() else {
1379        return;
1380    };
1381    let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
1382        return;
1383    };
1384    if spec_type != "speckit" {
1385        return;
1386    }
1387    let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
1388    let specs_dir = repo_root.join(dir);
1389    if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
1390        config.governance.constitution = Some(detected);
1391    }
1392}
1393
1394/// Loads the merged configuration for a repository.
1395///
1396/// Reads the user-level (global) config and the per-repo config, merging
1397/// them with repo settings taking precedence. Returns defaults if neither
1398/// file exists.
1399///
1400/// # Parameters
1401///
1402/// - `repo_root` — the repository root whose `.git-paw/config.toml` is the
1403///   repo-level config.
1404/// - `user_config_path` — controls which file is read as the user-level
1405///   (global) config:
1406///   - `None` resolves the user-level path via [`global_config_path`]
1407///     (platform default: `crate::dirs::config_dir().join("git-paw/config.toml")`).
1408///     This preserves v0.4 production behaviour and is what every internal
1409///     caller passes.
1410///   - `Some(p)` pins the user-level read to `p`. If `p` does not exist on
1411///     disk, the user-level side of the merge is the default `PawConfig`,
1412///     exactly as if no file existed at the platform-default path. This is
1413///     the discoverable test-isolation hook — pass an unused `TempDir`-rooted
1414///     path so the dev machine's real user-level config cannot leak into
1415///     the merged result.
1416///
1417/// See [`load_config_from`] for the lower-level primitive that takes both
1418/// paths explicitly (without the `Option` ergonomics).
1419pub fn load_config(
1420    repo_root: &Path,
1421    user_config_path: Option<&Path>,
1422) -> Result<PawConfig, PawError> {
1423    let global_path = match user_config_path {
1424        Some(p) => p.to_path_buf(),
1425        None => global_config_path()?,
1426    };
1427    load_config_from(&global_path, repo_root)
1428}
1429
1430/// Loads merged config from an explicit global path and repo root.
1431///
1432/// Applies post-merge auto-wiring for governance documents (see
1433/// [`auto_wire_governance`]).
1434pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
1435    let global = load_config_file(global_path)?.unwrap_or_default();
1436    let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1437    let mut merged = global.merged_with(&repo);
1438    auto_wire_governance(&mut merged, repo_root);
1439    Ok(merged)
1440}
1441
1442/// Saves a [`PawConfig`] to the repo-level config file (`.git-paw/config.toml`).
1443pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
1444    save_config_to(&repo_config_path(repo_root), config)
1445}
1446
1447/// Writes a [`PawConfig`] to a TOML file atomically (temp file + rename).
1448fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
1449    let dir = path
1450        .parent()
1451        .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
1452    fs::create_dir_all(dir)
1453        .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
1454
1455    let contents =
1456        toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
1457
1458    // Atomic write: temp file + rename
1459    let tmp = path.with_extension("toml.tmp");
1460    fs::write(&tmp, &contents)
1461        .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
1462    fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
1463
1464    Ok(())
1465}
1466
1467/// Adds a custom CLI to the global config.
1468///
1469/// If `command` is not an absolute path, it is resolved via PATH using `which`.
1470pub fn add_custom_cli(
1471    name: &str,
1472    command: &str,
1473    display_name: Option<&str>,
1474) -> Result<(), PawError> {
1475    add_custom_cli_to(&global_config_path()?, name, command, display_name)
1476}
1477
1478/// Adds a custom CLI to the config at the given path.
1479///
1480/// If `command` is not an absolute path, it is resolved via PATH using `which`.
1481pub fn add_custom_cli_to(
1482    config_path: &Path,
1483    name: &str,
1484    command: &str,
1485    display_name: Option<&str>,
1486) -> Result<(), PawError> {
1487    let resolved_command = if Path::new(command).is_absolute() {
1488        command.to_string()
1489    } else {
1490        which::which(command)
1491            .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
1492            .to_string_lossy()
1493            .into_owned()
1494    };
1495
1496    let mut config = load_config_file(config_path)?.unwrap_or_default();
1497
1498    config.clis.insert(
1499        name.to_string(),
1500        CustomCli {
1501            command: resolved_command,
1502            display_name: display_name.map(String::from),
1503            submit_delay_ms: None,
1504            settings_path: None,
1505        },
1506    );
1507
1508    save_config_to(config_path, &config)
1509}
1510
1511/// Returns a default `config.toml` string with sensible defaults and
1512/// commented-out v0.2.0 fields for discoverability.
1513#[allow(clippy::too_many_lines)] // single big string literal of example config
1514pub fn generate_default_config() -> String {
1515    r#"# git-paw configuration
1516# See https://github.com/bearicorn/git-paw for documentation.
1517
1518# Pre-select a CLI in the interactive picker (user can still change).
1519# Omit to show the full picker with no default.
1520# default_cli = ""
1521
1522# Enable tmux mouse mode for sessions (default: true).
1523# mouse = true
1524
1525# Bypass the CLI picker entirely for --from-specs mode.
1526# Omit to prompt or use per-spec paw_cli fields.
1527# default_spec_cli = ""
1528
1529# Prefix for spec-derived branch names (default: "spec/" ).
1530# branch_prefix = "spec/"
1531
1532# Where agent worktrees are created relative to the repository.
1533#   "child"   — inside the repo at .git-paw/worktrees/<branch-slug> (contained
1534#               layout; enables a project-scoped permission grant). New repos
1535#               default to this. Requires .git-paw/worktrees/ in .gitignore
1536#               (git paw init seeds it).
1537#   "sibling" — beside the repo at ../<project>-<branch-slug> (v0.7.0 layout).
1538# Omit the field to default to "sibling".
1539worktree_placement = "child"
1540
1541# Dashboard message log configuration.
1542# [dashboard]
1543# show_message_log = false
1544
1545# Spec scanning configuration.
1546# [specs]
1547# dir = "specs"
1548#
1549# OpenSpec format (directory-based, default):
1550# type = "openspec"
1551#
1552# Markdown format (frontmatter-based):
1553# type = "markdown"
1554# Each .md file uses YAML frontmatter fields:
1555#   paw_status  — "pending" | "done" | "in-progress" (required)
1556#   paw_branch  — branch name suffix (optional, falls back to filename)
1557#   paw_cli     — CLI override for this spec (optional)
1558
1559# Session logging configuration.
1560# [logging]
1561# enabled = false
1562
1563# HTTP broker for agent coordination (requires --broker flag on start).
1564# [broker]
1565# enabled = true
1566# port = 9119
1567# bind = "127.0.0.1"
1568
1569# Supervisor mode — git-paw acts as a coordinating layer in front of the
1570# agent CLI, enforcing approval policy and running configured gate
1571# commands during the five-gate verification workflow.
1572#
1573# Gate command templates feed the supervisor skill's five gates: gate 1
1574# Testing (fmt_check / lint / build / test), gate 3 Spec audit
1575# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
1576# (security_audit). When a key is omitted, the matching placeholder
1577# renders as `(not configured)` in the supervisor skill and the agent
1578# skips that tooling step (the gate's manual review still applies).
1579# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
1580# supervisor agent at verification time with the change name.
1581# [supervisor]
1582# enabled = true
1583# cli = "claude"
1584# test_command = "just check"                                  # or: "cargo test", "npm test", "pytest"
1585# lint_command = "cargo clippy -- -D warnings"                 # or: "npm run lint", "ruff check .", "golangci-lint run"
1586# build_command = "cargo build"                                # or: "npm run build", "mvn package", "go build ./..."
1587# fmt_check_command = "cargo fmt --check"                      # or: "prettier --check .", "gofmt -l ."
1588# doc_build_command = "mdbook build docs/"                     # or: "sphinx-build", "mkdocs build"
1589# doc_tool_command = "cargo doc --no-deps"                     # or: "sphinx-build -W docs docs/_build", "javadoc", "npx typedoc"
1590# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict"  # OpenSpec only
1591# security_audit_command = "cargo audit"                       # or: "npm audit", "bandit -r ."
1592# agent_approval = "auto"  # one of: "manual", "auto", "full-auto"
1593# verify_on_commit_nudge = true  # broker nudges the supervisor to verify each commit promptly (default true)
1594#
1595# Routing through the supervisor (the /tell and /agents commands). The user
1596# types in the supervisor pane and the supervisor routes the prompt to the
1597# named agent. `mode` selects the default delivery channel:
1598#   "feedback"  (default) — queue an agent.feedback; the agent picks it up on
1599#                           its next inbox poll. Safe for mixed-mode sessions.
1600#   "send-keys"           — inject the prompt directly into the target pane;
1601#                           used only when the target is in accept-edits mode,
1602#                           otherwise /tell falls back to feedback.
1603# `inventory_max_age_seconds` is how stale the cached /agents inventory may be
1604# before /tell or /agents re-polls the broker (default 60).
1605# [supervisor.tell]
1606# mode = "feedback"
1607# inventory_max_age_seconds = 60
1608#
1609# Conflict detector tuning. Active only when supervisor mode is enabled.
1610# [supervisor.conflict]
1611# window_seconds = 120          # escalate unresolved in-flight conflicts after this many seconds
1612# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
1613# escalate_on_violation = true  # also publish agent.question to supervisor on ownership violations
1614
1615# Common dev-command allowlist. When supervisor mode starts a session,
1616# git-paw seeds .claude/settings.json::allowed_bash_prefixes with the
1617# universal preset (non-destructive git verbs + find / grep / sed -n) so
1618# agents do not hit a permission prompt for each variant. Opt into a
1619# toolchain's curated grants with stacks (named presets: rust / node /
1620# python / go); extend with project-specific prefixes via extra. Opt out
1621# entirely by setting enabled = false.
1622# [supervisor.common_dev_allowlist]
1623# enabled = true
1624# stacks = ["rust"]
1625# extra = ["just", "mdbook build", "openspec validate"]
1626
1627# opsx (OpenSpec) role gating. When the session's spec engine is OpenSpec,
1628# git-paw's post-commit guard detects archive activity (`/opsx:archive` /
1629# `openspec archive`) by a non-supervisor agent and reacts per this mode:
1630#   "warn"  (default) — feedback to the offending agent + a permission_pattern
1631#                       learning the user sees in learnings.
1632#   "block"           — warn behaviour PLUS a feedback to the supervisor
1633#                       requesting it revert the offending commit.
1634#   "off"             — guard disabled entirely.
1635# The guard is inert under non-OpenSpec engines (speckit, markdown).
1636# [opsx]
1637# role_gating = "warn"
1638
1639# Custom CLI definitions.
1640# [clis.my-agent]
1641# command = "/usr/local/bin/my-agent"
1642# display_name = "My Agent"
1643
1644# Named presets for quick launches.
1645# [presets.my-preset]
1646# branches = ["feat/api", "fix/db"]
1647# cli = ""
1648"#
1649    .to_string()
1650}
1651
1652/// Removes a custom CLI from the global config.
1653///
1654/// Returns `PawError::CliNotFound` if the name is not present in the config.
1655pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1656    remove_custom_cli_from(&global_config_path()?, name)
1657}
1658
1659/// Removes a custom CLI from the config at the given path.
1660///
1661/// Returns `PawError::CliNotFound` if the name is not present in the config.
1662pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1663    let mut config = load_config_file(config_path)?.unwrap_or_default();
1664
1665    if config.clis.remove(name).is_none() {
1666        return Err(PawError::CliNotFound(name.to_string()));
1667    }
1668
1669    save_config_to(config_path, &config)
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675    use tempfile::TempDir;
1676
1677    fn write_file(path: &Path, content: &str) {
1678        if let Some(parent) = path.parent() {
1679            fs::create_dir_all(parent).unwrap();
1680        }
1681        fs::write(path, content).unwrap();
1682    }
1683
1684    // --- Parsing behavior ---
1685
1686    #[test]
1687    fn parses_config_with_all_fields() {
1688        let tmp = TempDir::new().unwrap();
1689        let path = tmp.path().join("config.toml");
1690        write_file(
1691            &path,
1692            r#"
1693default_cli = "claude"
1694mouse = false
1695default_spec_cli = "gemini"
1696branch_prefix = "spec/"
1697
1698[clis.my-agent]
1699command = "/usr/local/bin/my-agent"
1700display_name = "My Agent"
1701
1702[clis.local-llm]
1703command = "ollama-code"
1704
1705[presets.backend]
1706branches = ["feature/api", "fix/db"]
1707cli = "claude"
1708
1709[specs]
1710dir = "my-specs"
1711type = "openspec"
1712
1713[logging]
1714enabled = true
1715"#,
1716        );
1717
1718        let config = load_config_file(&path).unwrap().unwrap();
1719        assert_eq!(config.default_cli.as_deref(), Some("claude"));
1720        assert_eq!(config.mouse, Some(false));
1721        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1722        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1723        assert_eq!(config.clis.len(), 2);
1724        assert_eq!(
1725            config.clis["my-agent"].display_name.as_deref(),
1726            Some("My Agent")
1727        );
1728        assert_eq!(config.clis["local-llm"].command, "ollama-code");
1729        assert_eq!(config.presets["backend"].cli, "claude");
1730        assert_eq!(
1731            config.presets["backend"].branches,
1732            vec!["feature/api", "fix/db"]
1733        );
1734        let specs = config.specs.unwrap();
1735        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1736        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1737        let logging = config.logging.unwrap();
1738        assert!(logging.enabled);
1739    }
1740
1741    #[test]
1742    fn all_fields_are_optional() {
1743        let tmp = TempDir::new().unwrap();
1744        let path = tmp.path().join("config.toml");
1745        write_file(&path, "default_cli = \"gemini\"\n");
1746
1747        let config = load_config_file(&path).unwrap().unwrap();
1748        assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1749        assert_eq!(config.mouse, None);
1750        assert!(config.clis.is_empty());
1751        assert!(config.presets.is_empty());
1752    }
1753
1754    #[test]
1755    fn returns_defaults_when_no_files_exist() {
1756        let tmp = TempDir::new().unwrap();
1757        let global_path = tmp.path().join("nonexistent").join("config.toml");
1758        let repo_root = tmp.path().join("repo");
1759        fs::create_dir_all(&repo_root).unwrap();
1760
1761        let config = load_config_from(&global_path, &repo_root).unwrap();
1762        assert_eq!(config.default_cli, None);
1763        assert_eq!(config.mouse, None);
1764        assert!(config.clis.is_empty());
1765        assert!(config.presets.is_empty());
1766    }
1767
1768    #[test]
1769    fn reports_error_for_invalid_toml() {
1770        let tmp = TempDir::new().unwrap();
1771        let path = tmp.path().join("bad.toml");
1772        write_file(&path, "this is not [valid toml");
1773
1774        let err = load_config_file(&path).unwrap_err();
1775        assert!(err.to_string().contains("bad.toml"));
1776    }
1777
1778    // --- Merge behavior (through file I/O) ---
1779
1780    #[test]
1781    fn repo_config_overrides_global_scalars() {
1782        let tmp = TempDir::new().unwrap();
1783        let global_path = tmp.path().join("global").join("config.toml");
1784        let repo_root = tmp.path().join("repo");
1785        fs::create_dir_all(&repo_root).unwrap();
1786
1787        write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1788        write_file(
1789            &repo_config_path(&repo_root),
1790            "default_cli = \"gemini\"\n", // mouse intentionally absent
1791        );
1792
1793        let config = load_config_from(&global_path, &repo_root).unwrap();
1794        assert_eq!(config.default_cli.as_deref(), Some("gemini")); // repo wins
1795        assert_eq!(config.mouse, Some(true)); // global preserved when repo absent
1796    }
1797
1798    #[test]
1799    fn repo_config_merges_cli_maps() {
1800        let tmp = TempDir::new().unwrap();
1801        let global_path = tmp.path().join("global").join("config.toml");
1802        let repo_root = tmp.path().join("repo");
1803        fs::create_dir_all(&repo_root).unwrap();
1804
1805        write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1806        write_file(
1807            &repo_config_path(&repo_root),
1808            "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1809        );
1810
1811        let config = load_config_from(&global_path, &repo_root).unwrap();
1812        assert_eq!(config.clis.len(), 2);
1813        assert!(config.clis.contains_key("agent-a"));
1814        assert!(config.clis.contains_key("agent-b"));
1815    }
1816
1817    #[test]
1818    fn repo_cli_overrides_global_cli_with_same_name() {
1819        let tmp = TempDir::new().unwrap();
1820        let global_path = tmp.path().join("global").join("config.toml");
1821        let repo_root = tmp.path().join("repo");
1822        fs::create_dir_all(&repo_root).unwrap();
1823
1824        write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1825        write_file(
1826            &repo_config_path(&repo_root),
1827            "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1828        );
1829
1830        let config = load_config_from(&global_path, &repo_root).unwrap();
1831        assert_eq!(config.clis["my-agent"].command, "/new/path");
1832        assert_eq!(
1833            config.clis["my-agent"].display_name.as_deref(),
1834            Some("Overridden")
1835        );
1836    }
1837
1838    #[test]
1839    fn load_config_from_reads_global_file_when_no_repo() {
1840        let tmp = TempDir::new().unwrap();
1841        let global_path = tmp.path().join("global").join("config.toml");
1842        let repo_root = tmp.path().join("repo");
1843        fs::create_dir_all(&repo_root).unwrap();
1844
1845        write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1846        // No .git-paw/config.toml in repo_root
1847
1848        let config = load_config_from(&global_path, &repo_root).unwrap();
1849        assert_eq!(config.default_cli.as_deref(), Some("claude"));
1850        assert_eq!(config.mouse, Some(false));
1851    }
1852
1853    #[test]
1854    fn load_config_from_reads_repo_file_when_no_global() {
1855        let tmp = TempDir::new().unwrap();
1856        let global_path = tmp.path().join("nonexistent").join("config.toml");
1857        let repo_root = tmp.path().join("repo");
1858        fs::create_dir_all(&repo_root).unwrap();
1859
1860        write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1861
1862        let config = load_config_from(&global_path, &repo_root).unwrap();
1863        assert_eq!(config.default_cli.as_deref(), Some("codex"));
1864    }
1865
1866    // --- Preset behavior ---
1867
1868    #[test]
1869    fn preset_accessible_by_name() {
1870        let tmp = TempDir::new().unwrap();
1871        let global_path = tmp.path().join("global").join("config.toml");
1872        let repo_root = tmp.path().join("repo");
1873        fs::create_dir_all(&repo_root).unwrap();
1874
1875        write_file(
1876            &repo_config_path(&repo_root),
1877            "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1878        );
1879
1880        let config = load_config_from(&global_path, &repo_root).unwrap();
1881        let preset = config.get_preset("backend").unwrap();
1882        assert_eq!(preset.cli, "claude");
1883        assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1884    }
1885
1886    #[test]
1887    fn preset_returns_none_when_not_in_config() {
1888        let tmp = TempDir::new().unwrap();
1889        let global_path = tmp.path().join("config.toml");
1890        write_file(&global_path, "default_cli = \"claude\"\n");
1891
1892        let config = load_config_file(&global_path).unwrap().unwrap();
1893        assert!(config.get_preset("nonexistent").is_none());
1894    }
1895
1896    // --- add_custom_cli behavior ---
1897
1898    #[test]
1899    fn add_cli_writes_to_config_file() {
1900        let tmp = TempDir::new().unwrap();
1901        let config_path = tmp.path().join("git-paw").join("config.toml");
1902
1903        // Add a CLI with an absolute path (no PATH resolution needed)
1904        add_custom_cli_to(
1905            &config_path,
1906            "my-agent",
1907            "/usr/local/bin/my-agent",
1908            Some("My Agent"),
1909        )
1910        .unwrap();
1911
1912        // Verify by loading the file back
1913        let config = load_config_file(&config_path).unwrap().unwrap();
1914        assert_eq!(config.clis.len(), 1);
1915        assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1916        assert_eq!(
1917            config.clis["my-agent"].display_name.as_deref(),
1918            Some("My Agent")
1919        );
1920    }
1921
1922    #[test]
1923    fn add_cli_preserves_existing_entries() {
1924        let tmp = TempDir::new().unwrap();
1925        let config_path = tmp.path().join("git-paw").join("config.toml");
1926
1927        add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1928        add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1929
1930        let config = load_config_file(&config_path).unwrap().unwrap();
1931        assert_eq!(config.clis.len(), 2);
1932        assert!(config.clis.contains_key("first"));
1933        assert!(config.clis.contains_key("second"));
1934    }
1935
1936    #[test]
1937    fn add_cli_errors_when_command_not_on_path() {
1938        let tmp = TempDir::new().unwrap();
1939        let config_path = tmp.path().join("config.toml");
1940
1941        let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1942            .unwrap_err();
1943        assert!(err.to_string().contains("not found on PATH"));
1944    }
1945
1946    // --- remove_custom_cli behavior ---
1947
1948    #[test]
1949    fn remove_cli_deletes_entry_from_config_file() {
1950        let tmp = TempDir::new().unwrap();
1951        let config_path = tmp.path().join("git-paw").join("config.toml");
1952
1953        // Set up: add two CLIs
1954        add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1955        add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1956
1957        // Act: remove one
1958        remove_custom_cli_from(&config_path, "remove-me").unwrap();
1959
1960        // Verify: only the kept CLI remains
1961        let config = load_config_file(&config_path).unwrap().unwrap();
1962        assert_eq!(config.clis.len(), 1);
1963        assert!(config.clis.contains_key("keep-me"));
1964        assert!(!config.clis.contains_key("remove-me"));
1965    }
1966
1967    #[test]
1968    fn remove_nonexistent_cli_returns_cli_not_found_error() {
1969        let tmp = TempDir::new().unwrap();
1970        let config_path = tmp.path().join("config.toml");
1971        // Empty config file
1972        write_file(&config_path, "");
1973
1974        let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1975        match err {
1976            PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1977            other => panic!("expected CliNotFound, got: {other}"),
1978        }
1979    }
1980
1981    #[test]
1982    fn remove_cli_from_empty_config_returns_error() {
1983        let tmp = TempDir::new().unwrap();
1984        let config_path = tmp.path().join("config.toml");
1985        // No file at all
1986
1987        let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1988        match err {
1989            PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1990            other => panic!("expected CliNotFound, got: {other}"),
1991        }
1992    }
1993
1994    // --- Round-trip: config survives write + read ---
1995
1996    // --- default_spec_cli behavior ---
1997
1998    #[test]
1999    fn parses_default_spec_cli_when_present() {
2000        let tmp = TempDir::new().unwrap();
2001        let path = tmp.path().join("config.toml");
2002        write_file(&path, "default_spec_cli = \"claude\"\n");
2003
2004        let config = load_config_file(&path).unwrap().unwrap();
2005        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
2006    }
2007
2008    #[test]
2009    fn default_spec_cli_defaults_to_none() {
2010        let tmp = TempDir::new().unwrap();
2011        let path = tmp.path().join("config.toml");
2012        write_file(&path, "default_cli = \"claude\"\n");
2013
2014        let config = load_config_file(&path).unwrap().unwrap();
2015        assert_eq!(config.default_spec_cli, None);
2016    }
2017
2018    #[test]
2019    fn repo_overrides_global_default_spec_cli() {
2020        let tmp = TempDir::new().unwrap();
2021        let global_path = tmp.path().join("global").join("config.toml");
2022        let repo_root = tmp.path().join("repo");
2023        fs::create_dir_all(&repo_root).unwrap();
2024
2025        write_file(&global_path, "default_spec_cli = \"claude\"\n");
2026        write_file(
2027            &repo_config_path(&repo_root),
2028            "default_spec_cli = \"gemini\"\n",
2029        );
2030
2031        let config = load_config_from(&global_path, &repo_root).unwrap();
2032        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
2033    }
2034
2035    #[test]
2036    fn global_default_spec_cli_preserved_when_repo_absent() {
2037        let tmp = TempDir::new().unwrap();
2038        let global_path = tmp.path().join("global").join("config.toml");
2039        let repo_root = tmp.path().join("repo");
2040        fs::create_dir_all(&repo_root).unwrap();
2041
2042        write_file(&global_path, "default_spec_cli = \"claude\"\n");
2043
2044        let config = load_config_from(&global_path, &repo_root).unwrap();
2045        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
2046    }
2047
2048    // --- Round-trip: config survives write + read ---
2049
2050    #[test]
2051    fn config_survives_save_and_load() {
2052        let tmp = TempDir::new().unwrap();
2053        let config_path = tmp.path().join("config.toml");
2054
2055        let original = PawConfig {
2056            default_cli: Some("claude".into()),
2057            default_spec_cli: None,
2058            branch_prefix: None,
2059            mouse: Some(true),
2060            clis: HashMap::from([(
2061                "test".into(),
2062                CustomCli {
2063                    command: "/bin/test".into(),
2064                    display_name: Some("Test CLI".into()),
2065                    submit_delay_ms: None,
2066                    settings_path: None,
2067                },
2068            )]),
2069            presets: HashMap::from([(
2070                "dev".into(),
2071                Preset {
2072                    branches: vec!["main".into()],
2073                    cli: "claude".into(),
2074                },
2075            )]),
2076            specs: None,
2077            logging: None,
2078            dashboard: None,
2079            broker: BrokerConfig::default(),
2080            supervisor: None,
2081            governance: GovernanceConfig::default(),
2082            layout: None,
2083            opsx: None,
2084            mcp: McpConfig::default(),
2085            worktree_placement: Some(WorktreePlacement::Child),
2086        };
2087
2088        save_config_to(&config_path, &original).unwrap();
2089        let loaded = load_config_file(&config_path).unwrap().unwrap();
2090        assert_eq!(original, loaded);
2091    }
2092
2093    // --- Gap #1: Parse [specs] section with populated fields ---
2094
2095    #[test]
2096    fn parses_specs_section_with_populated_fields() {
2097        let tmp = TempDir::new().unwrap();
2098        let path = tmp.path().join("config.toml");
2099        write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
2100
2101        let config = load_config_file(&path).unwrap().unwrap();
2102        let specs = config.specs.unwrap();
2103        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
2104        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
2105    }
2106
2107    // --- Gap #2: Parse [logging] section with enabled ---
2108
2109    #[test]
2110    fn parses_logging_section_with_enabled() {
2111        let tmp = TempDir::new().unwrap();
2112        let path = tmp.path().join("config.toml");
2113        write_file(&path, "[logging]\nenabled = true\n");
2114
2115        let config = load_config_file(&path).unwrap().unwrap();
2116        let logging = config.logging.unwrap();
2117        assert!(logging.enabled);
2118    }
2119
2120    // --- Gap #3: Round-trip with specs and logging populated ---
2121
2122    #[test]
2123    fn round_trip_with_specs_and_logging() {
2124        let tmp = TempDir::new().unwrap();
2125        let config_path = tmp.path().join("config.toml");
2126
2127        let original = PawConfig {
2128            specs: Some(SpecsConfig {
2129                dir: Some("specs".into()),
2130                spec_type: Some("openspec".into()),
2131            }),
2132            logging: Some(LoggingConfig { enabled: true }),
2133            ..Default::default()
2134        };
2135
2136        save_config_to(&config_path, &original).unwrap();
2137        let loaded = load_config_file(&config_path).unwrap().unwrap();
2138        assert_eq!(original, loaded);
2139        assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
2140        assert!(loaded.logging.unwrap().enabled);
2141    }
2142
2143    // --- Gap #4: Generated config is valid TOML ---
2144
2145    #[test]
2146    fn generated_default_config_is_valid_toml() {
2147        let raw = generate_default_config();
2148        let stripped: String = raw
2149            .lines()
2150            .filter(|line| !line.trim_start().starts_with('#'))
2151            .collect::<Vec<&str>>()
2152            .join("\n");
2153
2154        let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
2155        assert!(
2156            parsed.is_ok(),
2157            "generated config with comments stripped should be valid TOML, got: {:?}",
2158            parsed.unwrap_err()
2159        );
2160    }
2161
2162    // --- Gap #5: branch_prefix merge ---
2163
2164    #[test]
2165    fn branch_prefix_repo_overrides_global() {
2166        let tmp = TempDir::new().unwrap();
2167        let global_path = tmp.path().join("global").join("config.toml");
2168        let repo_root = tmp.path().join("repo");
2169        fs::create_dir_all(&repo_root).unwrap();
2170
2171        write_file(&global_path, "branch_prefix = \"feat/\"\n");
2172        write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
2173
2174        let config = load_config_from(&global_path, &repo_root).unwrap();
2175        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
2176    }
2177
2178    #[test]
2179    fn generated_default_config_contains_commented_examples() {
2180        let output = generate_default_config();
2181        assert!(
2182            output.contains("default_spec_cli"),
2183            "should contain default_spec_cli"
2184        );
2185        assert!(
2186            output.contains("branch_prefix"),
2187            "should contain branch_prefix"
2188        );
2189        assert!(output.contains("[specs]"), "should contain [specs]");
2190        assert!(output.contains("[logging]"), "should contain [logging]");
2191        assert!(output.contains("[broker]"), "should contain [broker]");
2192    }
2193
2194    #[test]
2195    fn generated_default_config_contains_child_worktree_placement() {
2196        let output = generate_default_config();
2197        assert!(
2198            output.contains("worktree_placement = \"child\""),
2199            "generated config must set child worktree placement for new repos"
2200        );
2201        // The line must be active (not commented) so it actually takes effect.
2202        let parsed: PawConfig = toml::from_str(&output).expect("generated config parses");
2203        assert_eq!(
2204            parsed.worktree_placement(),
2205            WorktreePlacement::Child,
2206            "generated config must resolve to child placement"
2207        );
2208    }
2209
2210    // --- BrokerConfig ---
2211
2212    #[test]
2213    fn broker_config_defaults() {
2214        let config = BrokerConfig::default();
2215        assert!(!config.enabled);
2216        assert_eq!(config.port, 9119);
2217        assert_eq!(config.bind, "127.0.0.1");
2218    }
2219
2220    #[test]
2221    fn broker_config_url() {
2222        let config = BrokerConfig::default();
2223        assert_eq!(config.url(), "http://127.0.0.1:9119");
2224
2225        let custom = BrokerConfig {
2226            enabled: true,
2227            port: 8080,
2228            bind: "0.0.0.0".to_string(),
2229            ..Default::default()
2230        };
2231        assert_eq!(custom.url(), "http://0.0.0.0:8080");
2232    }
2233
2234    #[test]
2235    fn empty_config_gets_broker_defaults() {
2236        let tmp = TempDir::new().unwrap();
2237        let path = tmp.path().join("config.toml");
2238        write_file(&path, "");
2239
2240        let config = load_config_file(&path).unwrap().unwrap();
2241        assert!(!config.broker.enabled);
2242        assert_eq!(config.broker.port, 9119);
2243        assert_eq!(config.broker.bind, "127.0.0.1");
2244    }
2245
2246    #[test]
2247    fn parses_full_broker_section() {
2248        let tmp = TempDir::new().unwrap();
2249        let path = tmp.path().join("config.toml");
2250        write_file(
2251            &path,
2252            "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
2253        );
2254
2255        let config = load_config_file(&path).unwrap().unwrap();
2256        assert!(config.broker.enabled);
2257        assert_eq!(config.broker.port, 8080);
2258        assert_eq!(config.broker.bind, "0.0.0.0");
2259    }
2260
2261    #[test]
2262    fn parses_partial_broker_section() {
2263        let tmp = TempDir::new().unwrap();
2264        let path = tmp.path().join("config.toml");
2265        write_file(&path, "[broker]\nenabled = true\n");
2266
2267        let config = load_config_file(&path).unwrap().unwrap();
2268        assert!(config.broker.enabled);
2269        assert_eq!(config.broker.port, 9119);
2270        assert_eq!(config.broker.bind, "127.0.0.1");
2271    }
2272
2273    // --- SupervisorConfig ---
2274
2275    #[test]
2276    fn supervisor_is_none_when_section_absent() {
2277        let tmp = TempDir::new().unwrap();
2278        let path = tmp.path().join("config.toml");
2279        write_file(&path, "default_cli = \"claude\"\n");
2280
2281        let config = load_config_file(&path).unwrap().unwrap();
2282        assert!(config.supervisor.is_none());
2283    }
2284
2285    #[test]
2286    fn parses_full_supervisor_section() {
2287        let tmp = TempDir::new().unwrap();
2288        let path = tmp.path().join("config.toml");
2289        write_file(
2290            &path,
2291            "[supervisor]\n\
2292             enabled = true\n\
2293             cli = \"claude\"\n\
2294             test_command = \"just check\"\n\
2295             agent_approval = \"full-auto\"\n",
2296        );
2297
2298        let config = load_config_file(&path).unwrap().unwrap();
2299        let supervisor = config.supervisor.unwrap();
2300        assert!(supervisor.enabled);
2301        assert_eq!(supervisor.cli.as_deref(), Some("claude"));
2302        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2303        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2304    }
2305
2306    #[test]
2307    fn parses_partial_supervisor_section() {
2308        let tmp = TempDir::new().unwrap();
2309        let path = tmp.path().join("config.toml");
2310        write_file(&path, "[supervisor]\nenabled = true\n");
2311
2312        let config = load_config_file(&path).unwrap().unwrap();
2313        let supervisor = config.supervisor.unwrap();
2314        assert!(supervisor.enabled);
2315        assert_eq!(supervisor.cli, None);
2316        assert_eq!(supervisor.test_command, None);
2317        assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
2318    }
2319
2320    // --- verify_on_commit_nudge (per-commit-verification-v0-6-x) ---
2321
2322    #[test]
2323    fn verify_on_commit_nudge_defaults_true_when_absent() {
2324        let tmp = TempDir::new().unwrap();
2325        let path = tmp.path().join("config.toml");
2326        write_file(&path, "[supervisor]\nenabled = true\n");
2327
2328        let config = load_config_file(&path).unwrap().unwrap();
2329        let supervisor = config.supervisor.unwrap();
2330        assert_eq!(
2331            supervisor.verify_on_commit_nudge, None,
2332            "an omitted field must deserialise as None"
2333        );
2334        assert!(
2335            supervisor.verify_on_commit_nudge_enabled(),
2336            "an unset verify_on_commit_nudge must resolve to true (default on)"
2337        );
2338    }
2339
2340    #[test]
2341    fn verify_on_commit_nudge_explicit_false_disables() {
2342        let tmp = TempDir::new().unwrap();
2343        let path = tmp.path().join("config.toml");
2344        write_file(
2345            &path,
2346            "[supervisor]\nenabled = true\nverify_on_commit_nudge = false\n",
2347        );
2348
2349        let config = load_config_file(&path).unwrap().unwrap();
2350        let supervisor = config.supervisor.unwrap();
2351        assert_eq!(supervisor.verify_on_commit_nudge, Some(false));
2352        assert!(
2353            !supervisor.verify_on_commit_nudge_enabled(),
2354            "an explicit `false` must disable the nudge"
2355        );
2356    }
2357
2358    #[test]
2359    fn verify_on_commit_nudge_explicit_true_enables() {
2360        let tmp = TempDir::new().unwrap();
2361        let path = tmp.path().join("config.toml");
2362        write_file(
2363            &path,
2364            "[supervisor]\nenabled = true\nverify_on_commit_nudge = true\n",
2365        );
2366
2367        let config = load_config_file(&path).unwrap().unwrap();
2368        let supervisor = config.supervisor.unwrap();
2369        assert_eq!(supervisor.verify_on_commit_nudge, Some(true));
2370        assert!(supervisor.verify_on_commit_nudge_enabled());
2371    }
2372
2373    #[test]
2374    fn rejects_invalid_approval_level() {
2375        let tmp = TempDir::new().unwrap();
2376        let path = tmp.path().join("config.toml");
2377        write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
2378
2379        let err = load_config_file(&path).unwrap_err();
2380        assert!(
2381            err.to_string().contains("yolo"),
2382            "error should mention invalid value, got: {err}"
2383        );
2384    }
2385
2386    #[test]
2387    fn supervisor_round_trips_through_save_and_load() {
2388        let tmp = TempDir::new().unwrap();
2389        let config_path = tmp.path().join("config.toml");
2390
2391        let original = PawConfig {
2392            supervisor: Some(SupervisorConfig {
2393                enabled: true,
2394                cli: Some("claude".into()),
2395                test_command: Some("just check".into()),
2396                lint_command: None,
2397                build_command: None,
2398                doc_build_command: None,
2399                doc_tool_command: None,
2400                spec_validate_command: None,
2401                fmt_check_command: None,
2402                security_audit_command: None,
2403                agent_approval: ApprovalLevel::FullAuto,
2404                auto_approve: None,
2405                conflict: ConflictConfig::default(),
2406                learnings: false,
2407                learnings_config: LearningsConfig::default(),
2408                common_dev_allowlist: CommonDevAllowlistConfig::default(),
2409                verify_on_commit_nudge: None,
2410                strict_branch_guard: None,
2411                auto_revert: None,
2412                manual_approvals_log: None,
2413                tell: TellConfig::default(),
2414            }),
2415            ..Default::default()
2416        };
2417
2418        save_config_to(&config_path, &original).unwrap();
2419        let loaded = load_config_file(&config_path).unwrap().unwrap();
2420        assert_eq!(loaded.supervisor, original.supervisor);
2421    }
2422
2423    // --- manual_approvals_log (approval-pattern-surfacing) ---
2424
2425    #[test]
2426    fn manual_approvals_log_defaults_to_true_when_absent() {
2427        // [supervisor] present without the field → recording on by default.
2428        let tmp = TempDir::new().unwrap();
2429        let path = tmp.path().join("config.toml");
2430        write_file(&path, "[supervisor]\nenabled = true\n");
2431        let cfg = load_config_file(&path).unwrap().unwrap();
2432        let sup = cfg.supervisor.unwrap();
2433        assert_eq!(sup.manual_approvals_log, None);
2434        assert!(
2435            sup.manual_approvals_log_enabled(),
2436            "absent field must resolve to true"
2437        );
2438    }
2439
2440    #[test]
2441    fn manual_approvals_log_explicit_false_opts_out() {
2442        let tmp = TempDir::new().unwrap();
2443        let path = tmp.path().join("config.toml");
2444        write_file(
2445            &path,
2446            "[supervisor]\nenabled = true\nmanual_approvals_log = false\n",
2447        );
2448        let cfg = load_config_file(&path).unwrap().unwrap();
2449        let sup = cfg.supervisor.unwrap();
2450        assert_eq!(sup.manual_approvals_log, Some(false));
2451        assert!(!sup.manual_approvals_log_enabled());
2452    }
2453
2454    #[test]
2455    fn pre_v050_config_parses_with_manual_approvals_log_absent() {
2456        // A config produced before this change (no `manual_approvals_log`
2457        // field) parses cleanly and the resolver still yields true.
2458        let tmp = TempDir::new().unwrap();
2459        let path = tmp.path().join("config.toml");
2460        write_file(
2461            &path,
2462            "[supervisor]\nenabled = true\ncli = \"claude\"\nlearnings = true\n",
2463        );
2464        let cfg = load_config_file(&path).unwrap().unwrap();
2465        let sup = cfg.supervisor.unwrap();
2466        assert_eq!(sup.manual_approvals_log, None);
2467        assert!(sup.manual_approvals_log_enabled());
2468    }
2469
2470    // --- Gate-command fields (supervisor-gate-templating-v0-5-x) ---
2471
2472    #[test]
2473    fn strict_branch_guard_defaults_to_true_and_honours_opt_out() {
2474        // Absent field → enforcement on by default.
2475        let on = TempDir::new().unwrap();
2476        let on_path = on.path().join("config.toml");
2477        write_file(&on_path, "[supervisor]\nenabled = true\n");
2478        let cfg = load_config_file(&on_path).unwrap().unwrap();
2479        let sup = cfg.supervisor.unwrap();
2480        assert_eq!(sup.strict_branch_guard, None);
2481        assert!(sup.strict_branch_guard(), "default must resolve to true");
2482
2483        // Explicit opt-out → enforcement off (detection still applies).
2484        let off = TempDir::new().unwrap();
2485        let off_path = off.path().join("config.toml");
2486        write_file(
2487            &off_path,
2488            "[supervisor]\nenabled = true\nstrict_branch_guard = false\n",
2489        );
2490        let cfg = load_config_file(&off_path).unwrap().unwrap();
2491        let sup = cfg.supervisor.unwrap();
2492        assert_eq!(sup.strict_branch_guard, Some(false));
2493        assert!(!sup.strict_branch_guard());
2494    }
2495
2496    #[test]
2497    fn gate_command_fields_default_to_none() {
2498        let tmp = TempDir::new().unwrap();
2499        let path = tmp.path().join("config.toml");
2500        write_file(&path, "[supervisor]\nenabled = true\n");
2501
2502        let config = load_config_file(&path).unwrap().unwrap();
2503        let supervisor = config.supervisor.unwrap();
2504        assert_eq!(supervisor.test_command, None);
2505        assert_eq!(supervisor.lint_command, None);
2506        assert_eq!(supervisor.build_command, None);
2507        assert_eq!(supervisor.doc_build_command, None);
2508        assert_eq!(supervisor.doc_tool_command, None);
2509        assert_eq!(supervisor.spec_validate_command, None);
2510        assert_eq!(supervisor.fmt_check_command, None);
2511        assert_eq!(supervisor.security_audit_command, None);
2512    }
2513
2514    #[test]
2515    fn gate_command_fields_round_trip() {
2516        let tmp = TempDir::new().unwrap();
2517        let config_path = tmp.path().join("config.toml");
2518
2519        let original = PawConfig {
2520            supervisor: Some(SupervisorConfig {
2521                enabled: true,
2522                cli: Some("claude".into()),
2523                test_command: Some("just check".into()),
2524                lint_command: Some("cargo clippy -- -D warnings".into()),
2525                build_command: Some("cargo build".into()),
2526                doc_build_command: Some("mdbook build docs/".into()),
2527                doc_tool_command: Some("cargo doc --no-deps".into()),
2528                spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
2529                fmt_check_command: Some("cargo fmt --check".into()),
2530                security_audit_command: Some("cargo audit".into()),
2531                ..Default::default()
2532            }),
2533            ..Default::default()
2534        };
2535
2536        save_config_to(&config_path, &original).unwrap();
2537        let loaded = load_config_file(&config_path).unwrap().unwrap();
2538        assert_eq!(loaded.supervisor, original.supervisor);
2539    }
2540
2541    #[test]
2542    fn gate_command_fields_omit_from_toml_when_none() {
2543        let supervisor = SupervisorConfig {
2544            enabled: true,
2545            test_command: None,
2546            lint_command: None,
2547            build_command: None,
2548            doc_build_command: None,
2549            doc_tool_command: None,
2550            spec_validate_command: None,
2551            fmt_check_command: None,
2552            security_audit_command: None,
2553            ..Default::default()
2554        };
2555        let serialized = toml::to_string_pretty(&supervisor).unwrap();
2556        for key in [
2557            "test_command",
2558            "lint_command",
2559            "build_command",
2560            "doc_build_command",
2561            "doc_tool_command",
2562            "spec_validate_command",
2563            "fmt_check_command",
2564            "security_audit_command",
2565        ] {
2566            assert!(
2567                !serialized.contains(key),
2568                "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
2569            );
2570        }
2571    }
2572
2573    // --- doc_tool_command (lang-agnostic-skills) ---
2574
2575    #[test]
2576    fn doc_tool_command_default_none() {
2577        let tmp = TempDir::new().unwrap();
2578        let path = tmp.path().join("config.toml");
2579        write_file(&path, "[supervisor]\nenabled = true\n");
2580
2581        let config = load_config_file(&path).unwrap().unwrap();
2582        let supervisor = config.supervisor.unwrap();
2583        assert_eq!(supervisor.doc_tool_command, None);
2584    }
2585
2586    #[test]
2587    fn doc_tool_command_explicit_value_preserved() {
2588        let tmp = TempDir::new().unwrap();
2589        let path = tmp.path().join("config.toml");
2590        write_file(
2591            &path,
2592            "[supervisor]\n\
2593             enabled = true\n\
2594             doc_tool_command = \"sphinx-build -W docs docs/_build\"\n",
2595        );
2596
2597        let config = load_config_file(&path).unwrap().unwrap();
2598        let supervisor = config.supervisor.unwrap();
2599        assert_eq!(
2600            supervisor.doc_tool_command.as_deref(),
2601            Some("sphinx-build -W docs docs/_build"),
2602            "explicit doc_tool_command value (including all whitespace) must be preserved verbatim",
2603        );
2604    }
2605
2606    #[test]
2607    fn doc_tool_command_v0_5_config_parses_without_field() {
2608        // A v0.5.0 config that predates the doc_tool_command field SHALL
2609        // load cleanly with the field defaulting to None.
2610        let tmp = TempDir::new().unwrap();
2611        let path = tmp.path().join("config.toml");
2612        write_file(
2613            &path,
2614            "[supervisor]\n\
2615             enabled = true\n\
2616             test_command = \"just check\"\n\
2617             lint_command = \"cargo clippy -- -D warnings\"\n\
2618             build_command = \"cargo build\"\n\
2619             doc_build_command = \"mdbook build docs/\"\n",
2620        );
2621
2622        let config = load_config_file(&path).unwrap().unwrap();
2623        let supervisor = config.supervisor.unwrap();
2624        assert_eq!(supervisor.doc_tool_command, None);
2625        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2626    }
2627
2628    #[test]
2629    fn doc_tool_command_flows_into_gate_commands() {
2630        let supervisor = SupervisorConfig {
2631            doc_tool_command: Some("javadoc -d docs/api src/**/*.java".into()),
2632            ..Default::default()
2633        };
2634        let gates = supervisor.gate_commands();
2635        assert_eq!(
2636            gates.doc_tool_command,
2637            Some("javadoc -d docs/api src/**/*.java"),
2638        );
2639    }
2640
2641    // --- CommonDevAllowlistConfig ---
2642
2643    #[test]
2644    fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
2645        let tmp = TempDir::new().unwrap();
2646        let path = tmp.path().join("config.toml");
2647        write_file(&path, "[supervisor]\nenabled = true\n");
2648
2649        let config = load_config_file(&path).unwrap().unwrap();
2650        let supervisor = config.supervisor.unwrap();
2651        assert!(supervisor.common_dev_allowlist.enabled);
2652        assert!(supervisor.common_dev_allowlist.stacks.is_empty());
2653        assert!(supervisor.common_dev_allowlist.extra.is_empty());
2654    }
2655
2656    #[test]
2657    fn supervisor_common_dev_allowlist_stacks_parsed() {
2658        let tmp = TempDir::new().unwrap();
2659        let path = tmp.path().join("config.toml");
2660        write_file(
2661            &path,
2662            "[supervisor]\nenabled = true\n\
2663             [supervisor.common_dev_allowlist]\nstacks = [\"rust\", \"node\"]\n",
2664        );
2665
2666        let config = load_config_file(&path).unwrap().unwrap();
2667        let supervisor = config.supervisor.unwrap();
2668        assert_eq!(
2669            supervisor.common_dev_allowlist.stacks,
2670            vec!["rust".to_string(), "node".to_string()],
2671        );
2672        // extra still defaults to empty; enabled stays true.
2673        assert!(supervisor.common_dev_allowlist.extra.is_empty());
2674        assert!(supervisor.common_dev_allowlist.enabled);
2675    }
2676
2677    #[test]
2678    fn supervisor_common_dev_allowlist_disabled_opt_out() {
2679        let tmp = TempDir::new().unwrap();
2680        let path = tmp.path().join("config.toml");
2681        write_file(
2682            &path,
2683            "[supervisor]\nenabled = true\n\
2684             [supervisor.common_dev_allowlist]\nenabled = false\n",
2685        );
2686
2687        let config = load_config_file(&path).unwrap().unwrap();
2688        let supervisor = config.supervisor.unwrap();
2689        assert!(!supervisor.common_dev_allowlist.enabled);
2690        // extra still defaults to empty.
2691        assert!(supervisor.common_dev_allowlist.extra.is_empty());
2692    }
2693
2694    #[test]
2695    fn supervisor_common_dev_allowlist_extra_parsed() {
2696        let tmp = TempDir::new().unwrap();
2697        let path = tmp.path().join("config.toml");
2698        write_file(
2699            &path,
2700            "[supervisor]\nenabled = true\n\
2701             [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
2702        );
2703
2704        let config = load_config_file(&path).unwrap().unwrap();
2705        let supervisor = config.supervisor.unwrap();
2706        assert_eq!(
2707            supervisor.common_dev_allowlist.extra,
2708            vec!["pnpm test".to_string(), "deno fmt".to_string()],
2709        );
2710        // enabled stays at default true.
2711        assert!(supervisor.common_dev_allowlist.enabled);
2712    }
2713
2714    #[test]
2715    fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
2716        let tmp = TempDir::new().unwrap();
2717        let config_path = tmp.path().join("config.toml");
2718
2719        let original = PawConfig {
2720            supervisor: Some(SupervisorConfig {
2721                enabled: true,
2722                common_dev_allowlist: CommonDevAllowlistConfig {
2723                    enabled: false,
2724                    stacks: vec!["rust".into(), "node".into()],
2725                    extra: vec!["pnpm test".into(), "uv pip install".into()],
2726                },
2727                ..Default::default()
2728            }),
2729            ..Default::default()
2730        };
2731
2732        save_config_to(&config_path, &original).unwrap();
2733        let loaded = load_config_file(&config_path).unwrap().unwrap();
2734        assert_eq!(loaded.supervisor, original.supervisor);
2735    }
2736
2737    #[test]
2738    fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
2739        // A pre-v0.5 supervisor config that omits the new sub-table must
2740        // still load and yield the documented defaults.
2741        let tmp = TempDir::new().unwrap();
2742        let path = tmp.path().join("config.toml");
2743        write_file(
2744            &path,
2745            "[supervisor]\n\
2746             enabled = true\n\
2747             cli = \"claude\"\n\
2748             test_command = \"just check\"\n\
2749             agent_approval = \"auto\"\n\
2750             [supervisor.conflict]\n\
2751             window_seconds = 60\n",
2752        );
2753
2754        let config = load_config_file(&path).unwrap().unwrap();
2755        let supervisor = config.supervisor.unwrap();
2756        assert!(supervisor.common_dev_allowlist.enabled);
2757        assert!(supervisor.common_dev_allowlist.extra.is_empty());
2758    }
2759
2760    #[test]
2761    fn generated_default_config_template_contains_common_dev_allowlist_section() {
2762        let template = generate_default_config();
2763        assert!(
2764            template.contains("[supervisor.common_dev_allowlist]"),
2765            "default template should document the new sub-table",
2766        );
2767        assert!(
2768            template.contains("enabled = true"),
2769            "template should show the enabled default",
2770        );
2771        assert!(
2772            template.contains("extra ="),
2773            "template should illustrate the extra field",
2774        );
2775        assert!(
2776            template.contains("stacks ="),
2777            "template should illustrate the stacks field",
2778        );
2779    }
2780
2781    // --- LearningsConfig (learnings-mode) ---
2782
2783    #[test]
2784    fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
2785        // [supervisor] present without `learnings` → learnings = false
2786        let tmp = TempDir::new().unwrap();
2787        let path = tmp.path().join("config.toml");
2788        write_file(&path, "[supervisor]\nenabled = true\n");
2789
2790        let config = load_config_file(&path).unwrap().unwrap();
2791        let supervisor = config.supervisor.unwrap();
2792        assert!(!supervisor.learnings);
2793        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2794    }
2795
2796    #[test]
2797    fn learnings_true_loads() {
2798        let tmp = TempDir::new().unwrap();
2799        let path = tmp.path().join("config.toml");
2800        write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
2801
2802        let config = load_config_file(&path).unwrap().unwrap();
2803        let supervisor = config.supervisor.unwrap();
2804        assert!(supervisor.learnings);
2805        // Defaults still applied for the nested table.
2806        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2807    }
2808
2809    #[test]
2810    fn learnings_config_custom_flush_interval_is_honoured() {
2811        let tmp = TempDir::new().unwrap();
2812        let path = tmp.path().join("config.toml");
2813        write_file(
2814            &path,
2815            "[supervisor]\n\
2816             enabled = true\n\
2817             learnings = true\n\
2818             [supervisor.learnings_config]\n\
2819             flush_interval_seconds = 30\n",
2820        );
2821
2822        let config = load_config_file(&path).unwrap().unwrap();
2823        let supervisor = config.supervisor.unwrap();
2824        assert!(supervisor.learnings);
2825        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
2826    }
2827
2828    #[test]
2829    fn learnings_config_defaults_when_table_absent() {
2830        // [supervisor.learnings_config] omitted → flush_interval_seconds = 60
2831        let cfg = LearningsConfig::default();
2832        assert_eq!(cfg.flush_interval_seconds, 60);
2833    }
2834
2835    #[test]
2836    fn pre_v050_config_loads_with_learnings_false() {
2837        // A config produced before v0.5.0 (no `learnings` field, no
2838        // `[supervisor.learnings_config]` table) parses cleanly and yields
2839        // `learnings = false`.
2840        let tmp = TempDir::new().unwrap();
2841        let path = tmp.path().join("config.toml");
2842        write_file(
2843            &path,
2844            "default_cli = \"claude\"\n\
2845             [supervisor]\n\
2846             enabled = true\n\
2847             agent_approval = \"auto\"\n",
2848        );
2849
2850        let config = load_config_file(&path).unwrap().unwrap();
2851        let supervisor = config.supervisor.unwrap();
2852        assert!(!supervisor.learnings);
2853        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2854    }
2855
2856    #[test]
2857    fn learnings_round_trips_through_save_and_load() {
2858        let tmp = TempDir::new().unwrap();
2859        let config_path = tmp.path().join("config.toml");
2860
2861        let original = PawConfig {
2862            supervisor: Some(SupervisorConfig {
2863                enabled: true,
2864                learnings: true,
2865                learnings_config: LearningsConfig {
2866                    flush_interval_seconds: 90,
2867                    broker_publish: BrokerPublish::ForceOff,
2868                },
2869                ..Default::default()
2870            }),
2871            ..Default::default()
2872        };
2873
2874        save_config_to(&config_path, &original).unwrap();
2875        let loaded = load_config_file(&config_path).unwrap().unwrap();
2876        assert_eq!(loaded.supervisor, original.supervisor);
2877        let supervisor = loaded.supervisor.unwrap();
2878        assert!(supervisor.learnings);
2879        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
2880    }
2881
2882    #[test]
2883    fn existing_v030_config_loads_without_supervisor() {
2884        let tmp = TempDir::new().unwrap();
2885        let path = tmp.path().join("config.toml");
2886        write_file(
2887            &path,
2888            "default_cli = \"claude\"\n\
2889             mouse = true\n\
2890             [broker]\n\
2891             enabled = true\n\
2892             [logging]\n\
2893             enabled = false\n",
2894        );
2895
2896        let config = load_config_file(&path).unwrap().unwrap();
2897        assert_eq!(config.default_cli.as_deref(), Some("claude"));
2898        assert!(config.broker.enabled);
2899        assert!(config.supervisor.is_none());
2900    }
2901
2902    #[test]
2903    fn generated_default_config_contains_commented_supervisor_section() {
2904        let output = generate_default_config();
2905        assert!(output.contains("[supervisor]"));
2906        assert!(output.contains("enabled"));
2907        assert!(output.contains("test_command"));
2908        assert!(output.contains("agent_approval"));
2909    }
2910
2911    // --- DashboardConfig ---
2912
2913    #[test]
2914    fn dashboard_config_defaults_to_disabled() {
2915        let config = DashboardConfig::default();
2916        assert!(!config.show_message_log);
2917    }
2918
2919    #[test]
2920    fn parses_dashboard_section_with_show_message_log() {
2921        let tmp = TempDir::new().unwrap();
2922        let path = tmp.path().join("config.toml");
2923        write_file(&path, "[dashboard]\nshow_message_log = true\n");
2924
2925        let config = load_config_file(&path).unwrap().unwrap();
2926        let dashboard = config.dashboard.unwrap();
2927        assert!(dashboard.show_message_log);
2928    }
2929
2930    #[test]
2931    fn dashboard_is_none_when_section_absent() {
2932        let tmp = TempDir::new().unwrap();
2933        let path = tmp.path().join("config.toml");
2934        write_file(&path, "default_cli = \"claude\"\n");
2935
2936        let config = load_config_file(&path).unwrap().unwrap();
2937        assert!(config.dashboard.is_none());
2938    }
2939
2940    #[test]
2941    fn dashboard_merge_repo_wins() {
2942        let tmp = TempDir::new().unwrap();
2943        let global_path = tmp.path().join("global").join("config.toml");
2944        let repo_root = tmp.path().join("repo");
2945        fs::create_dir_all(&repo_root).unwrap();
2946
2947        write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2948        write_file(
2949            &repo_config_path(&repo_root),
2950            "[dashboard]\nshow_message_log = true\n",
2951        );
2952
2953        let config = load_config_from(&global_path, &repo_root).unwrap();
2954        let dashboard = config.dashboard.unwrap();
2955        assert!(dashboard.show_message_log);
2956    }
2957
2958    #[test]
2959    fn dashboard_round_trip_through_save_and_load() {
2960        let tmp = TempDir::new().unwrap();
2961        let config_path = tmp.path().join("config.toml");
2962
2963        let original = PawConfig {
2964            dashboard: Some(DashboardConfig {
2965                show_message_log: true,
2966                ..Default::default()
2967            }),
2968            ..Default::default()
2969        };
2970
2971        save_config_to(&config_path, &original).unwrap();
2972        let loaded = load_config_file(&config_path).unwrap().unwrap();
2973        assert_eq!(loaded.dashboard, original.dashboard);
2974        assert!(loaded.dashboard.unwrap().show_message_log);
2975    }
2976
2977    // --- BrokerLogConfig (dashboard-broker-log task 1.3) ---
2978
2979    #[test]
2980    fn broker_log_config_defaults() {
2981        // Task 1.3: default load — cap 500, visible on, height > 12.
2982        let cfg = BrokerLogConfig::default();
2983        assert_eq!(cfg.max_messages, 500);
2984        assert!(cfg.default_visible);
2985        assert!(
2986            cfg.height_lines > 12,
2987            "default height_lines must be strictly greater than the v0.6.0 fixed 12, got {}",
2988            cfg.height_lines,
2989        );
2990    }
2991
2992    #[test]
2993    fn dashboard_config_default_includes_broker_log_defaults() {
2994        // An entirely default DashboardConfig carries the documented
2995        // broker-log defaults so a bare `[dashboard]` section behaves
2996        // predictably.
2997        let cfg = DashboardConfig::default();
2998        assert_eq!(cfg.broker_log.max_messages, 500);
2999        assert!(cfg.broker_log.default_visible);
3000        assert!(cfg.broker_log.height_lines > 12);
3001    }
3002
3003    #[test]
3004    fn parses_broker_log_section_with_explicit_overrides() {
3005        // Task 1.3: explicit override load.
3006        let tmp = TempDir::new().unwrap();
3007        let path = tmp.path().join("config.toml");
3008        write_file(
3009            &path,
3010            "[dashboard.broker_log]\nmax_messages = 100\ndefault_visible = false\n",
3011        );
3012
3013        let config = load_config_file(&path).unwrap().unwrap();
3014        let dashboard = config.dashboard.unwrap();
3015        assert_eq!(dashboard.broker_log.max_messages, 100);
3016        assert!(!dashboard.broker_log.default_visible);
3017    }
3018
3019    #[test]
3020    fn broker_log_partial_section_fills_remaining_defaults() {
3021        // A `[dashboard.broker_log]` table that sets only one field still
3022        // loads the documented default for the other (per-field
3023        // `#[serde(default)]`).
3024        let tmp = TempDir::new().unwrap();
3025        let path = tmp.path().join("config.toml");
3026        write_file(&path, "[dashboard.broker_log]\nmax_messages = 42\n");
3027
3028        let config = load_config_file(&path).unwrap().unwrap();
3029        let broker_log = config.dashboard.unwrap().broker_log;
3030        assert_eq!(broker_log.max_messages, 42);
3031        assert!(
3032            broker_log.default_visible,
3033            "default_visible must fall back to true when omitted"
3034        );
3035        assert_eq!(
3036            broker_log.height_lines,
3037            BrokerLogConfig::default_height_lines(),
3038            "height_lines must fall back to the documented default when omitted"
3039        );
3040    }
3041
3042    #[test]
3043    fn height_lines_parses_explicit_value() {
3044        // Configuration scenario "height_lines explicitly configured": an
3045        // explicit `[dashboard.broker_log] height_lines = 24` loads as 24.
3046        let tmp = TempDir::new().unwrap();
3047        let path = tmp.path().join("config.toml");
3048        write_file(&path, "[dashboard.broker_log]\nheight_lines = 24\n");
3049
3050        let config = load_config_file(&path).unwrap().unwrap();
3051        let broker_log = config.dashboard.unwrap().broker_log;
3052        assert_eq!(broker_log.height_lines, 24);
3053    }
3054
3055    #[test]
3056    fn height_lines_absent_uses_default() {
3057        // Configuration scenario "height_lines absent uses the default": a
3058        // `[dashboard.broker_log]` table that omits the field loads the
3059        // documented default, which is strictly greater than 12.
3060        let tmp = TempDir::new().unwrap();
3061        let path = tmp.path().join("config.toml");
3062        write_file(&path, "[dashboard.broker_log]\ndefault_visible = true\n");
3063
3064        let config = load_config_file(&path).unwrap().unwrap();
3065        let broker_log = config.dashboard.unwrap().broker_log;
3066        assert_eq!(
3067            broker_log.height_lines,
3068            BrokerLogConfig::default_height_lines()
3069        );
3070        assert!(broker_log.height_lines > 12);
3071    }
3072
3073    #[test]
3074    fn v050_dashboard_section_without_broker_log_still_parses() {
3075        // Task 1.3: a v0.5.0 config that predates the broker_log table must
3076        // load unchanged, with the new section materialising at its default.
3077        let tmp = TempDir::new().unwrap();
3078        let path = tmp.path().join("config.toml");
3079        write_file(&path, "[dashboard]\nshow_message_log = true\n");
3080
3081        let config = load_config_file(&path).unwrap().unwrap();
3082        let dashboard = config.dashboard.unwrap();
3083        assert!(dashboard.show_message_log);
3084        assert_eq!(dashboard.broker_log, BrokerLogConfig::default());
3085    }
3086
3087    #[test]
3088    fn broker_log_round_trips_through_save_and_load() {
3089        let tmp = TempDir::new().unwrap();
3090        let config_path = tmp.path().join("config.toml");
3091
3092        let original = PawConfig {
3093            dashboard: Some(DashboardConfig {
3094                show_message_log: false,
3095                broker_log: BrokerLogConfig {
3096                    max_messages: 250,
3097                    default_visible: false,
3098                    height_lines: 30,
3099                },
3100            }),
3101            ..Default::default()
3102        };
3103
3104        save_config_to(&config_path, &original).unwrap();
3105        let loaded = load_config_file(&config_path).unwrap().unwrap();
3106        assert_eq!(loaded.dashboard, original.dashboard);
3107        // Configuration scenario "height_lines round-trips through save and
3108        // load": the re-parsed value matches what was written.
3109        assert_eq!(loaded.dashboard.unwrap().broker_log.height_lines, 30);
3110    }
3111
3112    #[test]
3113    fn get_dashboard_returns_none_when_not_configured() {
3114        let config = PawConfig::default();
3115        assert!(config.get_dashboard().is_none());
3116    }
3117
3118    #[test]
3119    fn get_dashboard_returns_config_when_present() {
3120        let config = PawConfig {
3121            dashboard: Some(DashboardConfig {
3122                show_message_log: true,
3123                ..Default::default()
3124            }),
3125            ..Default::default()
3126        };
3127        let dashboard = config.get_dashboard().unwrap();
3128        assert!(dashboard.show_message_log);
3129    }
3130
3131    // --- approval_flags mapping ---
3132
3133    #[test]
3134    fn approval_flags_claude_full_auto() {
3135        assert_eq!(
3136            approval_flags("claude", &ApprovalLevel::FullAuto),
3137            "--dangerously-skip-permissions"
3138        );
3139    }
3140
3141    #[test]
3142    fn approval_flags_codex_auto() {
3143        assert_eq!(
3144            approval_flags("codex", &ApprovalLevel::Auto),
3145            "--approval-mode=auto-edit"
3146        );
3147    }
3148
3149    #[test]
3150    fn approval_flags_codex_full_auto() {
3151        assert_eq!(
3152            approval_flags("codex", &ApprovalLevel::FullAuto),
3153            "--approval-mode=full-auto"
3154        );
3155    }
3156
3157    #[test]
3158    fn approval_flags_unknown_cli_is_empty() {
3159        assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
3160    }
3161
3162    #[test]
3163    fn approval_flags_manual_is_empty() {
3164        assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
3165        assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
3166    }
3167
3168    #[test]
3169    fn approval_flags_is_deterministic() {
3170        let first = approval_flags("claude", &ApprovalLevel::FullAuto);
3171        let second = approval_flags("claude", &ApprovalLevel::FullAuto);
3172        assert_eq!(first, second);
3173    }
3174
3175    #[test]
3176    fn supervisor_merge_repo_wins() {
3177        let tmp = TempDir::new().unwrap();
3178        let global_path = tmp.path().join("global").join("config.toml");
3179        let repo_root = tmp.path().join("repo");
3180        fs::create_dir_all(&repo_root).unwrap();
3181
3182        write_file(
3183            &global_path,
3184            "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
3185        );
3186        write_file(
3187            &repo_config_path(&repo_root),
3188            "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
3189        );
3190
3191        let config = load_config_from(&global_path, &repo_root).unwrap();
3192        let supervisor = config.supervisor.unwrap();
3193        assert!(supervisor.enabled);
3194        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
3195    }
3196
3197    #[test]
3198    fn broker_config_round_trip() {
3199        let tmp = TempDir::new().unwrap();
3200        let config_path = tmp.path().join("config.toml");
3201
3202        let original = PawConfig {
3203            broker: BrokerConfig {
3204                enabled: true,
3205                port: 9200,
3206                bind: "127.0.0.1".to_string(),
3207                ..Default::default()
3208            },
3209            ..Default::default()
3210        };
3211
3212        save_config_to(&config_path, &original).unwrap();
3213        let loaded = load_config_file(&config_path).unwrap().unwrap();
3214        assert_eq!(loaded.broker.enabled, original.broker.enabled);
3215        assert_eq!(loaded.broker.port, original.broker.port);
3216        assert_eq!(loaded.broker.bind, original.broker.bind);
3217    }
3218
3219    // --- AutoApproveConfig (auto-approve-patterns / approval-configuration) ---
3220
3221    #[test]
3222    fn auto_approve_defaults_match_spec() {
3223        let cfg = AutoApproveConfig::default();
3224        assert!(cfg.enabled, "enabled defaults to true");
3225        assert!(
3226            cfg.safe_commands.is_empty(),
3227            "safe_commands defaults to empty"
3228        );
3229        assert_eq!(cfg.stall_threshold_seconds, 30);
3230        assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
3231    }
3232
3233    #[test]
3234    fn auto_approve_section_absent_keeps_supervisor_simple() {
3235        let tmp = TempDir::new().unwrap();
3236        let path = tmp.path().join("config.toml");
3237        write_file(&path, "[supervisor]\nenabled = true\n");
3238        let config = load_config_file(&path).unwrap().unwrap();
3239        let supervisor = config.supervisor.unwrap();
3240        assert!(supervisor.auto_approve.is_none());
3241    }
3242
3243    #[test]
3244    fn auto_approve_section_parses_full_body() {
3245        let tmp = TempDir::new().unwrap();
3246        let path = tmp.path().join("config.toml");
3247        write_file(
3248            &path,
3249            "[supervisor]\n\
3250             enabled = true\n\
3251             [supervisor.auto_approve]\n\
3252             enabled = false\n\
3253             safe_commands = [\"just smoke\"]\n\
3254             stall_threshold_seconds = 60\n\
3255             approval_level = \"conservative\"\n",
3256        );
3257        let config = load_config_file(&path).unwrap().unwrap();
3258        let aa = config.supervisor.unwrap().auto_approve.unwrap();
3259        assert!(!aa.enabled);
3260        assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
3261        assert_eq!(aa.stall_threshold_seconds, 60);
3262        assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
3263    }
3264
3265    #[test]
3266    fn auto_approve_enabled_defaults_to_true_when_omitted() {
3267        let tmp = TempDir::new().unwrap();
3268        let path = tmp.path().join("config.toml");
3269        write_file(
3270            &path,
3271            "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
3272        );
3273        let config = load_config_file(&path).unwrap().unwrap();
3274        let aa = config.supervisor.unwrap().auto_approve.unwrap();
3275        assert!(aa.enabled, "enabled should default to true");
3276    }
3277
3278    #[test]
3279    fn auto_approve_off_preset_forces_disabled() {
3280        let cfg = AutoApproveConfig {
3281            enabled: true,
3282            approval_level: ApprovalLevelPreset::Off,
3283            ..AutoApproveConfig::default()
3284        };
3285        let resolved = cfg.resolved();
3286        assert!(!resolved.enabled, "Off preset must force enabled = false");
3287    }
3288
3289    // --- Bug 8: [broker.watcher] republish_working_ttl_seconds ---
3290
3291    #[test]
3292    fn watcher_ttl_defaults_to_sixty_when_absent() {
3293        let cfg = WatcherConfig::default();
3294        assert_eq!(cfg.republish_working_ttl_seconds(), 60);
3295    }
3296
3297    #[test]
3298    fn watcher_ttl_zero_disables() {
3299        let cfg = WatcherConfig {
3300            republish_working_ttl_seconds: Some(0),
3301        };
3302        assert_eq!(cfg.republish_working_ttl_seconds(), 0);
3303    }
3304
3305    #[test]
3306    fn watcher_ttl_below_floor_clamps_to_five() {
3307        let cfg = WatcherConfig {
3308            republish_working_ttl_seconds: Some(2),
3309        };
3310        assert_eq!(
3311            cfg.republish_working_ttl_seconds(),
3312            WatcherConfig::MIN_REPUBLISH_TTL_SECONDS
3313        );
3314    }
3315
3316    #[test]
3317    fn watcher_ttl_explicit_non_zero_is_preserved() {
3318        let cfg = WatcherConfig {
3319            republish_working_ttl_seconds: Some(120),
3320        };
3321        assert_eq!(cfg.republish_working_ttl_seconds(), 120);
3322    }
3323
3324    #[test]
3325    fn watcher_ttl_parses_from_broker_table() {
3326        let tmp = TempDir::new().unwrap();
3327        let path = tmp.path().join("config.toml");
3328        write_file(
3329            &path,
3330            "[broker]\nenabled = true\n[broker.watcher]\nrepublish_working_ttl_seconds = 0\n",
3331        );
3332        let config = load_config_file(&path).unwrap().unwrap();
3333        assert_eq!(config.broker.watcher.republish_working_ttl_seconds, Some(0));
3334        assert_eq!(config.broker.watcher.republish_working_ttl_seconds(), 0);
3335    }
3336
3337    #[test]
3338    fn approve_worktree_writes_defaults_to_true_when_absent() {
3339        // Spec scenario: default true auto-approves (field unset).
3340        let cfg = AutoApproveConfig::default();
3341        assert!(
3342            cfg.approve_worktree_writes(),
3343            "absent approve_worktree_writes must resolve to true"
3344        );
3345    }
3346
3347    #[test]
3348    fn approve_worktree_writes_explicit_false_resolves_false() {
3349        // Spec scenario: explicit false reverts to manual.
3350        let cfg = AutoApproveConfig {
3351            approve_worktree_writes: Some(false),
3352            ..AutoApproveConfig::default()
3353        };
3354        assert!(!cfg.approve_worktree_writes());
3355    }
3356
3357    #[test]
3358    fn approve_worktree_writes_parses_from_toml() {
3359        let tmp = TempDir::new().unwrap();
3360        let path = tmp.path().join("config.toml");
3361        write_file(
3362            &path,
3363            "[supervisor]\nenabled = true\n[supervisor.auto_approve]\napprove_worktree_writes = false\n",
3364        );
3365        let config = load_config_file(&path).unwrap().unwrap();
3366        let aa = config.supervisor.unwrap().auto_approve.unwrap();
3367        assert_eq!(aa.approve_worktree_writes, Some(false));
3368        assert!(!aa.approve_worktree_writes());
3369    }
3370
3371    #[test]
3372    fn auto_approve_threshold_floor_clamps() {
3373        let cfg = AutoApproveConfig {
3374            stall_threshold_seconds: 0,
3375            ..AutoApproveConfig::default()
3376        };
3377        let resolved = cfg.resolved();
3378        assert_eq!(
3379            resolved.stall_threshold_seconds,
3380            AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
3381        );
3382    }
3383
3384    #[test]
3385    fn auto_approve_safe_preset_keeps_defaults() {
3386        let cfg = AutoApproveConfig {
3387            approval_level: ApprovalLevelPreset::Safe,
3388            ..AutoApproveConfig::default()
3389        };
3390        let wl = cfg.effective_whitelist();
3391        assert!(wl.iter().any(|c| c == "cargo test"));
3392        assert!(wl.iter().any(|c| c == "git push"));
3393        assert!(wl.iter().any(|c| c.starts_with("curl")));
3394    }
3395
3396    #[test]
3397    fn auto_approve_conservative_drops_push_and_curl() {
3398        let cfg = AutoApproveConfig {
3399            approval_level: ApprovalLevelPreset::Conservative,
3400            ..AutoApproveConfig::default()
3401        };
3402        let wl = cfg.effective_whitelist();
3403        assert!(wl.iter().any(|c| c == "cargo test"));
3404        assert!(
3405            !wl.iter().any(|c| c.starts_with("git push")),
3406            "conservative drops git push"
3407        );
3408        assert!(
3409            !wl.iter().any(|c| c.starts_with("curl")),
3410            "conservative drops curl"
3411        );
3412    }
3413
3414    #[test]
3415    fn auto_approve_extras_are_unioned_with_defaults() {
3416        let cfg = AutoApproveConfig {
3417            safe_commands: vec!["just lint".to_string(), "just test".to_string()],
3418            ..AutoApproveConfig::default()
3419        };
3420        let wl = cfg.effective_whitelist();
3421        assert!(wl.iter().any(|c| c == "cargo fmt"));
3422        assert!(wl.iter().any(|c| c == "just lint"));
3423        assert!(wl.iter().any(|c| c == "just test"));
3424    }
3425
3426    #[test]
3427    fn auto_approve_empty_extras_keep_defaults() {
3428        let cfg = AutoApproveConfig::default();
3429        let wl = cfg.effective_whitelist();
3430        assert!(wl.iter().any(|c| c == "cargo test"));
3431    }
3432
3433    /// Spec scenario `auto-approve-patterns/safe-command-classification`:
3434    /// "Config adds project-specific patterns" — a TOML config with
3435    /// `safe_commands = ["just smoke"]` must yield an effective whitelist
3436    /// such that `is_safe_command("just smoke -v", &whitelist)` is true.
3437    /// "Config does not weaken defaults" — `safe_commands = []` must keep
3438    /// the built-in defaults available to `is_safe_command`.
3439    #[test]
3440    fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
3441        use crate::supervisor::auto_approve::is_safe_command;
3442
3443        // (1) Extras case: a project-specific entry parsed from TOML must
3444        //     classify a command using that prefix as safe.
3445        let tmp = TempDir::new().unwrap();
3446        let extras_path = tmp.path().join("extras.toml");
3447        write_file(
3448            &extras_path,
3449            "[supervisor]\n\
3450             enabled = true\n\
3451             [supervisor.auto_approve]\n\
3452             safe_commands = [\"just smoke\"]\n",
3453        );
3454        let extras_config = load_config_file(&extras_path).unwrap().unwrap();
3455        let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
3456        let extras_whitelist = extras_aa.effective_whitelist();
3457        assert!(
3458            is_safe_command("just smoke -v", &extras_whitelist),
3459            "TOML extra `just smoke` must accept `just smoke -v`"
3460        );
3461        // The defaults must still be present alongside the extra.
3462        assert!(
3463            is_safe_command("cargo test", &extras_whitelist),
3464            "extras must not displace built-in defaults"
3465        );
3466
3467        // (2) Empty extras: the effective whitelist must still classify the
3468        //     built-in defaults (e.g. `cargo test`) as safe.
3469        let empty_path = tmp.path().join("empty.toml");
3470        write_file(
3471            &empty_path,
3472            "[supervisor]\n\
3473             enabled = true\n\
3474             [supervisor.auto_approve]\n\
3475             safe_commands = []\n",
3476        );
3477        let empty_config = load_config_file(&empty_path).unwrap().unwrap();
3478        let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
3479        let empty_whitelist = empty_aa.effective_whitelist();
3480        assert!(
3481            is_safe_command("cargo test", &empty_whitelist),
3482            "empty safe_commands must keep built-in defaults"
3483        );
3484        assert!(
3485            is_safe_command("cargo fmt --check", &empty_whitelist),
3486            "empty safe_commands must keep `cargo fmt` default"
3487        );
3488        // A command outside the defaults must still be rejected.
3489        assert!(
3490            !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
3491            "empty safe_commands must not whitelist arbitrary commands"
3492        );
3493    }
3494
3495    // --- ConflictConfig (supervisor.conflict sub-table) ---
3496
3497    #[test]
3498    fn conflict_config_defaults_match_spec() {
3499        let cfg = ConflictConfig::default();
3500        assert_eq!(cfg.window_seconds, 120);
3501        assert!(cfg.warn_on_intent_overlap);
3502        assert!(cfg.escalate_on_violation);
3503    }
3504
3505    #[test]
3506    fn supervisor_with_no_conflict_section_loads_defaults() {
3507        let tmp = TempDir::new().unwrap();
3508        let path = tmp.path().join("config.toml");
3509        write_file(&path, "[supervisor]\nenabled = true\n");
3510        let supervisor = load_config_file(&path)
3511            .unwrap()
3512            .unwrap()
3513            .supervisor
3514            .unwrap();
3515        assert_eq!(supervisor.conflict.window_seconds, 120);
3516        assert!(supervisor.conflict.warn_on_intent_overlap);
3517        assert!(supervisor.conflict.escalate_on_violation);
3518    }
3519
3520    #[test]
3521    fn conflict_section_with_all_fields_overrides_defaults() {
3522        let tmp = TempDir::new().unwrap();
3523        let path = tmp.path().join("config.toml");
3524        write_file(
3525            &path,
3526            "[supervisor]\n\
3527             enabled = true\n\
3528             [supervisor.conflict]\n\
3529             window_seconds = 300\n\
3530             warn_on_intent_overlap = false\n\
3531             escalate_on_violation = false\n",
3532        );
3533        let conflict = load_config_file(&path)
3534            .unwrap()
3535            .unwrap()
3536            .supervisor
3537            .unwrap()
3538            .conflict;
3539        assert_eq!(conflict.window_seconds, 300);
3540        assert!(!conflict.warn_on_intent_overlap);
3541        assert!(!conflict.escalate_on_violation);
3542    }
3543
3544    #[test]
3545    fn conflict_section_with_partial_fields_keeps_other_defaults() {
3546        let tmp = TempDir::new().unwrap();
3547        let path = tmp.path().join("config.toml");
3548        write_file(
3549            &path,
3550            "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
3551        );
3552        let conflict = load_config_file(&path)
3553            .unwrap()
3554            .unwrap()
3555            .supervisor
3556            .unwrap()
3557            .conflict;
3558        assert_eq!(conflict.window_seconds, 60);
3559        assert!(conflict.warn_on_intent_overlap);
3560        assert!(conflict.escalate_on_violation);
3561    }
3562
3563    #[test]
3564    fn pre_v05_config_without_conflict_section_loads() {
3565        let tmp = TempDir::new().unwrap();
3566        let path = tmp.path().join("config.toml");
3567        // A v0.4-style config: supervisor enabled but no [supervisor.conflict].
3568        write_file(
3569            &path,
3570            "default_cli = \"claude\"\n\
3571             [supervisor]\n\
3572             enabled = true\n\
3573             agent_approval = \"auto\"\n",
3574        );
3575        let config = load_config_file(&path).unwrap().unwrap();
3576        let supervisor = config.supervisor.unwrap();
3577        assert!(supervisor.enabled);
3578        // The conflict sub-table defaults to ConflictConfig::default().
3579        assert_eq!(supervisor.conflict, ConflictConfig::default());
3580    }
3581
3582    #[test]
3583    fn conflict_config_round_trips_through_save_and_load() {
3584        let tmp = TempDir::new().unwrap();
3585        let config_path = tmp.path().join("config.toml");
3586        let original = PawConfig {
3587            supervisor: Some(SupervisorConfig {
3588                enabled: true,
3589                conflict: ConflictConfig {
3590                    window_seconds: 90,
3591                    warn_on_intent_overlap: false,
3592                    escalate_on_violation: true,
3593                },
3594                ..Default::default()
3595            }),
3596            ..Default::default()
3597        };
3598        save_config_to(&config_path, &original).unwrap();
3599        let loaded = load_config_file(&config_path).unwrap().unwrap();
3600        assert_eq!(loaded.supervisor, original.supervisor);
3601    }
3602
3603    #[test]
3604    fn v030_config_loads_without_auto_approve() {
3605        // Backward-compat: an existing v0.3.0 config that has neither
3606        // [supervisor] nor [supervisor.auto_approve] must parse cleanly.
3607        let tmp = TempDir::new().unwrap();
3608        let path = tmp.path().join("config.toml");
3609        write_file(
3610            &path,
3611            "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
3612        );
3613        let config = load_config_file(&path).unwrap().unwrap();
3614        assert!(config.supervisor.is_none());
3615        assert!(config.broker.enabled);
3616    }
3617
3618    // --- GovernanceConfig (governance-config v0.5.0) ---
3619
3620    /// Helper: lays out a repo with `.git-paw/config.toml` and an optional
3621    /// `SpecKit` `memory/constitution.md` so the `load_config_from`
3622    /// auto-wiring path can be exercised end-to-end.
3623    fn write_repo_config(repo_root: &Path, toml: &str) {
3624        write_file(&repo_config_path(repo_root), toml);
3625    }
3626
3627    fn missing_global(tmp: &TempDir) -> PathBuf {
3628        tmp.path().join("nonexistent-global").join("config.toml")
3629    }
3630
3631    // 3.1 No [governance] section → all paths None.
3632    #[test]
3633    fn governance_defaults_to_all_none_when_section_absent() {
3634        let tmp = TempDir::new().unwrap();
3635        let path = tmp.path().join("config.toml");
3636        write_file(&path, "default_cli = \"claude\"\n");
3637
3638        let config = load_config_file(&path).unwrap().unwrap();
3639        assert!(config.governance.adr.is_none());
3640        assert!(config.governance.test_strategy.is_none());
3641        assert!(config.governance.security.is_none());
3642        assert!(config.governance.dod.is_none());
3643        assert!(config.governance.constitution.is_none());
3644    }
3645
3646    // 3.2 All paths populated.
3647    #[test]
3648    fn governance_all_paths_populated() {
3649        let tmp = TempDir::new().unwrap();
3650        let path = tmp.path().join("config.toml");
3651        write_file(
3652            &path,
3653            "[governance]\n\
3654             adr = \"docs/adr\"\n\
3655             test_strategy = \"docs/test-strategy.md\"\n\
3656             security = \"docs/security-checklist.md\"\n\
3657             dod = \"docs/definition-of-done.md\"\n\
3658             constitution = \".specify/memory/constitution.md\"\n",
3659        );
3660
3661        let config = load_config_file(&path).unwrap().unwrap();
3662        assert_eq!(
3663            config.governance.adr.as_deref(),
3664            Some(Path::new("docs/adr"))
3665        );
3666        assert_eq!(
3667            config.governance.test_strategy.as_deref(),
3668            Some(Path::new("docs/test-strategy.md"))
3669        );
3670        assert_eq!(
3671            config.governance.security.as_deref(),
3672            Some(Path::new("docs/security-checklist.md"))
3673        );
3674        assert_eq!(
3675            config.governance.dod.as_deref(),
3676            Some(Path::new("docs/definition-of-done.md"))
3677        );
3678        assert_eq!(
3679            config.governance.constitution.as_deref(),
3680            Some(Path::new(".specify/memory/constitution.md"))
3681        );
3682    }
3683
3684    // 3.3 Partial paths.
3685    #[test]
3686    fn governance_partial_paths_only_some_fields_populated() {
3687        let tmp = TempDir::new().unwrap();
3688        let path = tmp.path().join("config.toml");
3689        write_file(
3690            &path,
3691            "[governance]\n\
3692             dod = \"docs/dod.md\"\n\
3693             security = \"docs/security.md\"\n",
3694        );
3695
3696        let config = load_config_file(&path).unwrap().unwrap();
3697        assert_eq!(
3698            config.governance.dod.as_deref(),
3699            Some(Path::new("docs/dod.md"))
3700        );
3701        assert_eq!(
3702            config.governance.security.as_deref(),
3703            Some(Path::new("docs/security.md"))
3704        );
3705        assert!(config.governance.adr.is_none());
3706        assert!(config.governance.test_strategy.is_none());
3707        assert!(config.governance.constitution.is_none());
3708    }
3709
3710    // 3.4 Absolute path preserved as-is.
3711    #[test]
3712    fn governance_absolute_path_preserved_as_is() {
3713        let tmp = TempDir::new().unwrap();
3714        let path = tmp.path().join("config.toml");
3715        write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
3716
3717        let config = load_config_file(&path).unwrap().unwrap();
3718        assert_eq!(
3719            config.governance.adr,
3720            Some(PathBuf::from("/absolute/path/to/adr"))
3721        );
3722    }
3723
3724    // 3.5 Non-existent path loads cleanly without error.
3725    #[test]
3726    fn governance_nonexistent_path_loads_cleanly() {
3727        let tmp = TempDir::new().unwrap();
3728        let path = tmp.path().join("config.toml");
3729        write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
3730
3731        let config = load_config_file(&path).unwrap().unwrap();
3732        assert_eq!(
3733            config.governance.dod,
3734            Some(PathBuf::from("docs/never-existed.md"))
3735        );
3736    }
3737
3738    // 3.6 Round-trip via save → load.
3739    #[test]
3740    fn governance_round_trips_through_save_and_load() {
3741        let tmp = TempDir::new().unwrap();
3742        let config_path = tmp.path().join("config.toml");
3743
3744        let original = PawConfig {
3745            governance: GovernanceConfig {
3746                adr: Some(PathBuf::from("docs/adr")),
3747                test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
3748                security: Some(PathBuf::from("docs/security.md")),
3749                dod: Some(PathBuf::from("docs/dod.md")),
3750                constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
3751                readme: Some(PathBuf::from("README.md")),
3752                docs: Some(PathBuf::from("docs/src")),
3753            },
3754            ..Default::default()
3755        };
3756
3757        save_config_to(&config_path, &original).unwrap();
3758        let loaded = load_config_file(&config_path).unwrap().unwrap();
3759        assert_eq!(loaded.governance, original.governance);
3760    }
3761
3762    // 3.7 v0.4 fixture (no [governance]) loads with defaults.
3763    #[test]
3764    fn governance_v04_config_without_section_loads_with_defaults() {
3765        let tmp = TempDir::new().unwrap();
3766        let path = tmp.path().join("config.toml");
3767        write_file(
3768            &path,
3769            "default_cli = \"claude\"\n\
3770             mouse = true\n\
3771             [broker]\n\
3772             enabled = true\n\
3773             [supervisor]\n\
3774             enabled = true\n\
3775             [specs]\n\
3776             dir = \"specs\"\n\
3777             type = \"openspec\"\n\
3778             [clis.foo]\n\
3779             command = \"/bin/foo\"\n",
3780        );
3781
3782        let config = load_config_file(&path).unwrap().unwrap();
3783        assert_eq!(config.governance, GovernanceConfig::default());
3784        assert!(config.governance.adr.is_none());
3785        assert!(config.governance.test_strategy.is_none());
3786        assert!(config.governance.security.is_none());
3787        assert!(config.governance.dod.is_none());
3788        assert!(config.governance.constitution.is_none());
3789        assert!(config.governance.readme.is_none());
3790        assert!(config.governance.docs.is_none());
3791    }
3792
3793    // 3.8 GovernanceConfig::default() exposes only the documented path fields
3794    // (no `gates` field) — compile-time-style assertion via destructuring.
3795    #[test]
3796    fn governance_default_has_only_path_fields() {
3797        // If a future change adds a `gates` (or any other) field, this
3798        // destructure stops compiling, forcing the change author to
3799        // revisit the capability boundary explicitly.
3800        let GovernanceConfig {
3801            adr,
3802            test_strategy,
3803            security,
3804            dod,
3805            constitution,
3806            readme,
3807            docs,
3808        } = GovernanceConfig::default();
3809        assert!(adr.is_none());
3810        assert!(test_strategy.is_none());
3811        assert!(security.is_none());
3812        assert!(dod.is_none());
3813        assert!(constitution.is_none());
3814        assert!(readme.is_none());
3815        assert!(docs.is_none());
3816    }
3817
3818    // governance-config delta: readme + docs parse from [governance].
3819    #[test]
3820    fn governance_parses_readme_and_docs_fields() {
3821        let tmp = TempDir::new().unwrap();
3822        let path = tmp.path().join("config.toml");
3823        write_file(
3824            &path,
3825            "[governance]\n\
3826             readme = \"README.md\"\n\
3827             docs = \"docs/src\"\n",
3828        );
3829        let config = load_config_file(&path).unwrap().unwrap();
3830        assert_eq!(config.governance.readme, Some(PathBuf::from("README.md")));
3831        assert_eq!(config.governance.docs, Some(PathBuf::from("docs/src")));
3832    }
3833
3834    // governance-config delta: readme + docs default to None when omitted.
3835    #[test]
3836    fn governance_readme_and_docs_default_to_none_when_omitted() {
3837        let tmp = TempDir::new().unwrap();
3838        let path = tmp.path().join("config.toml");
3839        write_file(&path, "[governance]\ndod = \"docs/dod.md\"\n");
3840        let config = load_config_file(&path).unwrap().unwrap();
3841        assert!(config.governance.readme.is_none());
3842        assert!(config.governance.docs.is_none());
3843        assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
3844    }
3845
3846    // governance-config delta: readme + docs survive round-trip serialization.
3847    #[test]
3848    fn governance_readme_and_docs_round_trip() {
3849        let original = GovernanceConfig {
3850            readme: Some(PathBuf::from("README.md")),
3851            docs: Some(PathBuf::from("docs/src")),
3852            ..Default::default()
3853        };
3854        let toml_str = toml::to_string(&original).unwrap();
3855        let reparsed: GovernanceConfig = toml::from_str(&toml_str).unwrap();
3856        assert_eq!(reparsed.readme, original.readme);
3857        assert_eq!(reparsed.docs, original.docs);
3858    }
3859
3860    // 4.1 Auto-wires constitution when SpecKit detected + field unset.
3861    #[test]
3862    fn governance_auto_wires_constitution_when_speckit_detected() {
3863        let tmp = TempDir::new().unwrap();
3864        let repo_root = tmp.path().join("repo");
3865        let specify = repo_root.join(".specify");
3866        let specs = specify.join("specs");
3867        let memory = specify.join("memory");
3868        fs::create_dir_all(&specs).unwrap();
3869        fs::create_dir_all(&memory).unwrap();
3870        let constitution = memory.join("constitution.md");
3871        fs::write(&constitution, "# Constitution\n").unwrap();
3872
3873        write_repo_config(
3874            &repo_root,
3875            "[specs]\n\
3876             type = \"speckit\"\n\
3877             dir = \".specify/specs\"\n",
3878        );
3879
3880        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3881        assert_eq!(
3882            config.governance.constitution.as_deref(),
3883            Some(constitution.as_path())
3884        );
3885    }
3886
3887    // 4.2 Explicit governance.constitution preserved unchanged.
3888    #[test]
3889    fn governance_explicit_constitution_preserved_over_auto_wiring() {
3890        let tmp = TempDir::new().unwrap();
3891        let repo_root = tmp.path().join("repo");
3892        let specify = repo_root.join(".specify");
3893        let specs = specify.join("specs");
3894        let memory = specify.join("memory");
3895        fs::create_dir_all(&specs).unwrap();
3896        fs::create_dir_all(&memory).unwrap();
3897        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3898
3899        write_repo_config(
3900            &repo_root,
3901            "[specs]\n\
3902             type = \"speckit\"\n\
3903             dir = \".specify/specs\"\n\
3904             [governance]\n\
3905             constitution = \"docs/principles.md\"\n",
3906        );
3907
3908        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3909        assert_eq!(
3910            config.governance.constitution,
3911            Some(PathBuf::from("docs/principles.md"))
3912        );
3913    }
3914
3915    // 4.3 Auto-wiring skipped for non-speckit backends.
3916    #[test]
3917    fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
3918        let tmp = TempDir::new().unwrap();
3919        let repo_root = tmp.path().join("repo");
3920        let specify = repo_root.join(".specify");
3921        let memory = specify.join("memory");
3922        fs::create_dir_all(&memory).unwrap();
3923        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3924        fs::create_dir_all(repo_root.join("specs")).unwrap();
3925
3926        write_repo_config(
3927            &repo_root,
3928            "[specs]\n\
3929             type = \"openspec\"\n\
3930             dir = \"specs\"\n",
3931        );
3932
3933        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3934        assert!(config.governance.constitution.is_none());
3935    }
3936
3937    // 4.4 Auto-wiring skipped when [specs] is absent entirely.
3938    #[test]
3939    fn governance_auto_wiring_skipped_when_specs_section_absent() {
3940        let tmp = TempDir::new().unwrap();
3941        let repo_root = tmp.path().join("repo");
3942        let memory = repo_root.join(".specify").join("memory");
3943        fs::create_dir_all(&memory).unwrap();
3944        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3945        fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
3946
3947        write_repo_config(&repo_root, "default_cli = \"claude\"\n");
3948
3949        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3950        assert!(config.governance.constitution.is_none());
3951    }
3952
3953    // 4.5 SpecKit active but constitution.md absent → stays None, no error.
3954    #[test]
3955    fn governance_auto_wiring_skipped_when_constitution_md_absent() {
3956        let tmp = TempDir::new().unwrap();
3957        let repo_root = tmp.path().join("repo");
3958        let specs = repo_root.join(".specify").join("specs");
3959        fs::create_dir_all(&specs).unwrap();
3960        // No memory/constitution.md.
3961
3962        write_repo_config(
3963            &repo_root,
3964            "[specs]\n\
3965             type = \"speckit\"\n\
3966             dir = \".specify/specs\"\n",
3967        );
3968
3969        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3970        assert!(config.governance.constitution.is_none());
3971    }
3972
3973    // 4.6 Explicit empty-string constitution preserved as Some("").
3974    #[test]
3975    fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
3976        let tmp = TempDir::new().unwrap();
3977        let repo_root = tmp.path().join("repo");
3978        let specify = repo_root.join(".specify");
3979        let specs = specify.join("specs");
3980        let memory = specify.join("memory");
3981        fs::create_dir_all(&specs).unwrap();
3982        fs::create_dir_all(&memory).unwrap();
3983        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3984
3985        write_repo_config(
3986            &repo_root,
3987            "[specs]\n\
3988             type = \"speckit\"\n\
3989             dir = \".specify/specs\"\n\
3990             [governance]\n\
3991             constitution = \"\"\n",
3992        );
3993
3994        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3995        assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
3996    }
3997
3998    // Merge: global and repo each contribute independent paths.
3999    #[test]
4000    fn governance_merge_fields_independently_across_global_and_repo() {
4001        let tmp = TempDir::new().unwrap();
4002        let global_path = tmp.path().join("global").join("config.toml");
4003        let repo_root = tmp.path().join("repo");
4004        fs::create_dir_all(&repo_root).unwrap();
4005
4006        write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
4007        write_file(
4008            &repo_config_path(&repo_root),
4009            "[governance]\ndod = \"docs/dod.md\"\n",
4010        );
4011
4012        let config = load_config_from(&global_path, &repo_root).unwrap();
4013        assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
4014        assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
4015    }
4016
4017    // Merge precedence: repo wins per-field when both set.
4018    #[test]
4019    fn governance_merge_repo_wins_per_field_when_both_set() {
4020        let tmp = TempDir::new().unwrap();
4021        let global_path = tmp.path().join("global").join("config.toml");
4022        let repo_root = tmp.path().join("repo");
4023        fs::create_dir_all(&repo_root).unwrap();
4024
4025        write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
4026        write_file(
4027            &repo_config_path(&repo_root),
4028            "[governance]\nadr = \"docs/repo-adr\"\n",
4029        );
4030
4031        let config = load_config_from(&global_path, &repo_root).unwrap();
4032        assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
4033    }
4034
4035    // load_repo_config also applies auto-wiring.
4036    #[test]
4037    fn governance_load_repo_config_also_auto_wires_constitution() {
4038        let tmp = TempDir::new().unwrap();
4039        let repo_root = tmp.path().join("repo");
4040        let specify = repo_root.join(".specify");
4041        let specs = specify.join("specs");
4042        let memory = specify.join("memory");
4043        fs::create_dir_all(&specs).unwrap();
4044        fs::create_dir_all(&memory).unwrap();
4045        let constitution = memory.join("constitution.md");
4046        fs::write(&constitution, "# Constitution\n").unwrap();
4047
4048        write_repo_config(
4049            &repo_root,
4050            "[specs]\n\
4051             type = \"speckit\"\n\
4052             dir = \".specify/specs\"\n",
4053        );
4054
4055        let config = load_repo_config(&repo_root).unwrap();
4056        assert_eq!(
4057            config.governance.constitution.as_deref(),
4058            Some(constitution.as_path())
4059        );
4060    }
4061
4062    // --- load_config user_config_path override (config-test-isolation) ---
4063
4064    #[test]
4065    fn load_config_with_some_pins_global_to_override_path() {
4066        let tmp = TempDir::new().unwrap();
4067        let repo_root = tmp.path().join("repo");
4068        fs::create_dir_all(&repo_root).unwrap();
4069
4070        let global_a = tmp.path().join("global-A.toml");
4071        let global_b = tmp.path().join("global-B.toml");
4072        write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
4073        write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
4074
4075        let config = load_config(&repo_root, Some(&global_a)).unwrap();
4076        assert!(config.clis.contains_key("cli-A"));
4077        assert!(!config.clis.contains_key("cli-B"));
4078    }
4079
4080    #[test]
4081    fn load_config_with_some_nonexistent_returns_defaults() {
4082        let tmp = TempDir::new().unwrap();
4083        let repo_root = tmp.path().join("repo");
4084        fs::create_dir_all(&repo_root).unwrap();
4085        let missing = tmp.path().join("does-not-exist.toml");
4086
4087        let config = load_config(&repo_root, Some(&missing)).unwrap();
4088        assert_eq!(config, PawConfig::default());
4089    }
4090
4091    // Note: a `load_config_with_none_reads_platform_default_global` test is
4092    // intentionally omitted. Asserting that `None` resolves to
4093    // `global_config_path()` would require either writing to the dev
4094    // machine's real `~/Library/Application Support/git-paw/config.toml`
4095    // (polluting it) or `serial_test` + env-var manipulation of `HOME` /
4096    // `XDG_CONFIG_HOME` (brittle, slows the suite). The `None` branch is
4097    // covered behaviourally by the 8 production call sites in `src/main.rs`
4098    // and the v0.4 test suite that continues to pass.
4099
4100    #[test]
4101    fn load_config_override_does_not_affect_repo_resolution() {
4102        let tmp = TempDir::new().unwrap();
4103        let repo_root = tmp.path().join("repo");
4104        fs::create_dir_all(&repo_root).unwrap();
4105        write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
4106
4107        let global_path = tmp.path().join("global.toml");
4108        write_file(&global_path, "default_cli = \"gemini\"\n");
4109
4110        let config = load_config(&repo_root, Some(&global_path)).unwrap();
4111        assert_eq!(config.default_cli.as_deref(), Some("claude"));
4112    }
4113
4114    // Maps to scenario "GovernanceConfig has no gates field" from
4115    // governance-config. The struct does not enable `deny_unknown_fields`, so
4116    // unknown sections deserialise silently; this test asserts the round-trip
4117    // representation omits any `[governance.gates]` section and the loaded
4118    // governance config keeps only the documented document-pointer fields.
4119    // (test-coverage-v0-5-0 task 9.1)
4120    #[test]
4121    fn governance_config_rejects_gates_field() {
4122        let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
4123        let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
4124        let gov = cfg.governance;
4125        assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
4126
4127        let round_trip = toml::to_string(&gov).expect("serialise gov");
4128        assert!(
4129            !round_trip.contains("gates"),
4130            "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
4131        );
4132        assert!(
4133            !round_trip.contains("[governance.gates]"),
4134            "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
4135        );
4136    }
4137
4138    // -----------------------------------------------------------------------
4139    // supervisor-pane-affordances: `[layout].border_affordances` config field
4140    // (spec requirement "border_affordances config field").
4141    // -----------------------------------------------------------------------
4142
4143    /// Scenario: Default true applies all affordances — absent `[layout]`
4144    /// section resolves to `true`.
4145    #[test]
4146    fn border_affordances_defaults_to_true_when_layout_absent() {
4147        let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("toml parse");
4148        assert!(
4149            cfg.layout.is_none(),
4150            "no [layout] section should parse as None"
4151        );
4152        assert!(
4153            cfg.border_affordances_enabled(),
4154            "border affordances default to on when [layout] is absent"
4155        );
4156    }
4157
4158    /// Scenario: Default true — `[layout]` present but `border_affordances`
4159    /// unset still resolves to `true`.
4160    #[test]
4161    fn border_affordances_defaults_to_true_when_field_unset() {
4162        let cfg: PawConfig = toml::from_str("[layout]\n").expect("toml parse");
4163        assert!(
4164            cfg.border_affordances_enabled(),
4165            "border affordances default to on when the field is unset"
4166        );
4167    }
4168
4169    /// Scenario: Explicit false skips all affordances.
4170    #[test]
4171    fn border_affordances_explicit_false_resolves_off() {
4172        let cfg: PawConfig =
4173            toml::from_str("[layout]\nborder_affordances = false\n").expect("toml parse");
4174        assert_eq!(cfg.layout.as_ref().unwrap().border_affordances, Some(false));
4175        assert!(
4176            !cfg.border_affordances_enabled(),
4177            "explicit false must resolve to off"
4178        );
4179    }
4180
4181    /// Scenario: Explicit true round-trips and resolves on.
4182    #[test]
4183    fn border_affordances_explicit_true_resolves_on() {
4184        let cfg: PawConfig =
4185            toml::from_str("[layout]\nborder_affordances = true\n").expect("toml parse");
4186        assert!(cfg.border_affordances_enabled());
4187    }
4188
4189    /// Backward compatibility: a representative v0.5.0 config (no `[layout]`
4190    /// section at all) still parses and defaults affordances on.
4191    #[test]
4192    fn v0_5_0_config_without_layout_parses() {
4193        let v0_5_0 = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4194        let cfg: PawConfig = toml::from_str(v0_5_0).expect("v0.5.0 config must still parse");
4195        assert!(cfg.layout.is_none());
4196        assert!(cfg.border_affordances_enabled());
4197    }
4198
4199    /// `merged_with`: an overlay `[layout]` wins over the base layout.
4200    #[test]
4201    fn layout_overlay_wins_in_merge() {
4202        let base: PawConfig =
4203            toml::from_str("[layout]\nborder_affordances = true\n").expect("base");
4204        let overlay: PawConfig =
4205            toml::from_str("[layout]\nborder_affordances = false\n").expect("overlay");
4206        let merged = base.merged_with(&overlay);
4207        assert!(
4208            !merged.border_affordances_enabled(),
4209            "overlay [layout] must win in the merge"
4210        );
4211    }
4212
4213    /// `merged_with`: an absent overlay `[layout]` preserves the base layout.
4214    #[test]
4215    fn layout_base_preserved_when_overlay_absent() {
4216        let base: PawConfig =
4217            toml::from_str("[layout]\nborder_affordances = false\n").expect("base");
4218        let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4219        let merged = base.merged_with(&overlay);
4220        assert!(
4221            !merged.border_affordances_enabled(),
4222            "base [layout] must survive when the overlay has none"
4223        );
4224    }
4225
4226    // --- opsx role-gating config (opsx-role-gating 1.4) ---
4227
4228    #[test]
4229    fn role_gating_defaults_to_warn_when_section_absent() {
4230        // A v0.5.0-shaped config with no `[opsx]` section still parses and
4231        // resolves to the default Warn mode.
4232        let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4233        assert!(config.opsx.is_none());
4234        assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4235    }
4236
4237    #[test]
4238    fn role_gating_section_present_but_field_absent_resolves_warn() {
4239        let config: PawConfig = toml::from_str("[opsx]\n").expect("parses");
4240        assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4241    }
4242
4243    #[test]
4244    fn role_gating_explicit_warn() {
4245        let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("parses");
4246        assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4247    }
4248
4249    #[test]
4250    fn role_gating_explicit_block() {
4251        let config: PawConfig =
4252            toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("parses");
4253        assert_eq!(config.role_gating_mode(), RoleGatingMode::Block);
4254    }
4255
4256    #[test]
4257    fn role_gating_explicit_off() {
4258        let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("parses");
4259        assert_eq!(config.role_gating_mode(), RoleGatingMode::Off);
4260    }
4261
4262    #[test]
4263    fn role_gating_invalid_value_is_a_parse_error() {
4264        let err = toml::from_str::<PawConfig>("[opsx]\nrole_gating = \"loud\"\n").unwrap_err();
4265        assert!(
4266            err.to_string().contains("role_gating") || err.to_string().contains("variant"),
4267            "got: {err}"
4268        );
4269    }
4270
4271    #[test]
4272    fn role_gating_mode_round_trips_through_toml() {
4273        let config = PawConfig {
4274            opsx: Some(OpsxConfig {
4275                role_gating: Some(RoleGatingMode::Block),
4276            }),
4277            ..Default::default()
4278        };
4279        let serialized = toml::to_string(&config).expect("serializes");
4280        assert!(
4281            serialized.contains("role_gating = \"block\""),
4282            "got: {serialized}"
4283        );
4284        let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4285        assert_eq!(reparsed.role_gating_mode(), RoleGatingMode::Block);
4286    }
4287
4288    #[test]
4289    fn opsx_section_merges_with_overlay_winning() {
4290        let base: PawConfig =
4291            toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("base parses");
4292        let overlay: PawConfig =
4293            toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("overlay parses");
4294        let merged = base.merged_with(&overlay);
4295        assert_eq!(merged.role_gating_mode(), RoleGatingMode::Block);
4296    }
4297
4298    #[test]
4299    fn opsx_section_base_preserved_when_overlay_absent() {
4300        let base: PawConfig =
4301            toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("base parses");
4302        let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4303        let merged = base.merged_with(&overlay);
4304        assert_eq!(merged.role_gating_mode(), RoleGatingMode::Off);
4305    }
4306
4307    #[test]
4308    fn supervisor_auto_revert_defaults_false() {
4309        let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4310        let sup = config.supervisor.expect("supervisor present");
4311        assert!(!sup.auto_revert(), "auto_revert defaults to false");
4312    }
4313
4314    #[test]
4315    fn supervisor_auto_revert_explicit_true() {
4316        let config: PawConfig =
4317            toml::from_str("[supervisor]\nenabled = true\nauto_revert = true\n").expect("parses");
4318        let sup = config.supervisor.expect("supervisor present");
4319        assert!(sup.auto_revert());
4320    }
4321
4322    // --- [supervisor.tell] (supervisor-tell change) ---
4323
4324    #[test]
4325    fn tell_config_defaults_when_table_absent() {
4326        // A v0.5.0 `[supervisor]` with no `[supervisor.tell]` table loads the
4327        // documented defaults: feedback mode, 60s inventory max age.
4328        let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4329        let sup = config.supervisor.expect("supervisor present");
4330        assert_eq!(sup.tell.mode, TellMode::Feedback);
4331        assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4332        assert!(sup.tell.is_default());
4333    }
4334
4335    #[test]
4336    fn tell_config_explicit_feedback_loads() {
4337        let config: PawConfig = toml::from_str(
4338            "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"feedback\"\n",
4339        )
4340        .expect("parses");
4341        let sup = config.supervisor.expect("supervisor present");
4342        assert_eq!(sup.tell.mode, TellMode::Feedback);
4343        // mode set explicitly to the default still resolves to default values.
4344        assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4345    }
4346
4347    #[test]
4348    fn tell_config_explicit_send_keys_loads() {
4349        let config: PawConfig = toml::from_str(
4350            "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"send-keys\"\ninventory_max_age_seconds = 15\n",
4351        )
4352        .expect("parses");
4353        let sup = config.supervisor.expect("supervisor present");
4354        assert_eq!(sup.tell.mode, TellMode::SendKeys);
4355        assert_eq!(sup.tell.inventory_max_age_seconds, 15);
4356        assert!(!sup.tell.is_default());
4357    }
4358
4359    #[test]
4360    fn tell_config_rejects_unknown_mode() {
4361        let err = toml::from_str::<PawConfig>(
4362            "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"shout\"\n",
4363        )
4364        .unwrap_err();
4365        assert!(
4366            err.to_string().contains("shout") || err.to_string().contains("mode"),
4367            "unknown mode should be a parse error; got {err}"
4368        );
4369    }
4370
4371    #[test]
4372    fn tell_config_all_default_table_round_trips_without_emitting_tell() {
4373        // An all-default tell table is skipped on serialize so v0.5.0 configs
4374        // stay byte-stable.
4375        let sup = SupervisorConfig {
4376            enabled: true,
4377            ..SupervisorConfig::default()
4378        };
4379        let config = PawConfig {
4380            supervisor: Some(sup),
4381            ..PawConfig::default()
4382        };
4383        let serialized = toml::to_string_pretty(&config).expect("serializes");
4384        assert!(
4385            !serialized.contains("[supervisor.tell]"),
4386            "all-default tell table must be omitted; got:\n{serialized}"
4387        );
4388        let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4389        assert_eq!(config, reparsed);
4390    }
4391
4392    // --- [mcp] configuration section (mcp-server-identity) ---
4393
4394    // configuration delta — Scenario: Config with [mcp] name parses the field.
4395    #[test]
4396    fn mcp_name_parses_to_some() {
4397        let config: PawConfig = toml::from_str("[mcp]\nname = \"my-project\"\n").expect("parses");
4398        assert_eq!(config.mcp.name, Some("my-project".to_string()));
4399        assert_eq!(config.mcp_server_name(), "my-project");
4400    }
4401
4402    // configuration delta — Scenario: Config without [mcp] section loads with
4403    // defaults (name = None) and does not error.
4404    #[test]
4405    fn mcp_section_absent_defaults_to_none() {
4406        let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4407        assert_eq!(config.mcp, McpConfig::default());
4408        assert!(config.mcp.name.is_none());
4409        assert_eq!(config.mcp_server_name(), "git-paw");
4410    }
4411
4412    // Backward compatibility: a representative pre-v0.7.0 config (no [mcp]
4413    // section) still parses unchanged.
4414    #[test]
4415    fn pre_existing_config_without_mcp_loads() {
4416        let prior = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4417        let config: PawConfig = toml::from_str(prior).expect("prior config must still parse");
4418        assert_eq!(config.mcp, McpConfig::default());
4419    }
4420
4421    // configuration delta — Scenario: MCP config survives round-trip
4422    // serialization.
4423    #[test]
4424    fn mcp_config_round_trips_through_toml() {
4425        let config = PawConfig {
4426            mcp: McpConfig {
4427                name: Some("my-project".to_string()),
4428            },
4429            ..PawConfig::default()
4430        };
4431        let serialized = toml::to_string(&config).expect("serializes");
4432        let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4433        assert_eq!(reparsed.mcp, config.mcp);
4434    }
4435
4436    // An all-default [mcp] table (name = None) is omitted on serialize so
4437    // pre-existing configs stay byte-stable.
4438    #[test]
4439    fn mcp_default_omits_name_on_serialize() {
4440        let config = PawConfig::default();
4441        let serialized = toml::to_string_pretty(&config).expect("serializes");
4442        assert!(
4443            !serialized.contains("name ="),
4444            "default [mcp] must not emit a name; got:\n{serialized}"
4445        );
4446        let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4447        assert_eq!(config, reparsed);
4448    }
4449
4450    // merged_with: a repo-level [mcp].name wins over the global one.
4451    #[test]
4452    fn mcp_overlay_name_wins_in_merge() {
4453        let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4454        let overlay: PawConfig = toml::from_str("[mcp]\nname = \"repo-name\"\n").expect("overlay");
4455        let merged = base.merged_with(&overlay);
4456        assert_eq!(merged.mcp.name, Some("repo-name".to_string()));
4457    }
4458
4459    // merged_with: an absent overlay [mcp].name preserves the base name.
4460    #[test]
4461    fn mcp_base_name_preserved_when_overlay_absent() {
4462        let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4463        let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4464        let merged = base.merged_with(&overlay);
4465        assert_eq!(merged.mcp.name, Some("global-name".to_string()));
4466    }
4467
4468    // --- worktree_placement (worktree-embedded-placement) ---
4469
4470    #[test]
4471    fn worktree_placement_parses_child() {
4472        let cfg: PawConfig =
4473            toml::from_str("worktree_placement = \"child\"\n").expect("parse child");
4474        assert_eq!(cfg.worktree_placement, Some(WorktreePlacement::Child));
4475        assert_eq!(cfg.worktree_placement(), WorktreePlacement::Child);
4476    }
4477
4478    #[test]
4479    fn worktree_placement_parses_sibling() {
4480        let cfg: PawConfig =
4481            toml::from_str("worktree_placement = \"sibling\"\n").expect("parse sibling");
4482        assert_eq!(cfg.worktree_placement, Some(WorktreePlacement::Sibling));
4483        assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4484    }
4485
4486    #[test]
4487    fn worktree_placement_absent_defaults_to_sibling() {
4488        let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parse");
4489        assert_eq!(cfg.worktree_placement, None);
4490        assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4491    }
4492
4493    #[test]
4494    fn worktree_placement_repo_overrides_global() {
4495        let tmp = TempDir::new().unwrap();
4496        let global_path = tmp.path().join("global").join("config.toml");
4497        let repo_root = tmp.path().join("repo");
4498        fs::create_dir_all(&repo_root).unwrap();
4499
4500        write_file(&global_path, "worktree_placement = \"sibling\"\n");
4501        write_file(
4502            &repo_config_path(&repo_root),
4503            "worktree_placement = \"child\"\n",
4504        );
4505
4506        let config = load_config_from(&global_path, &repo_root).unwrap();
4507        assert_eq!(config.worktree_placement(), WorktreePlacement::Child);
4508    }
4509
4510    #[test]
4511    fn worktree_placement_survives_round_trip() {
4512        let cfg = PawConfig {
4513            worktree_placement: Some(WorktreePlacement::Child),
4514            ..PawConfig::default()
4515        };
4516        let serialized = toml::to_string_pretty(&cfg).expect("serialize");
4517        let reparsed: PawConfig = toml::from_str(&serialized).expect("reparse");
4518        assert_eq!(reparsed.worktree_placement(), WorktreePlacement::Child);
4519    }
4520
4521    #[test]
4522    fn worktree_placement_default_skipped_on_serialize() {
4523        // A default (absent) placement must not appear in serialized output so
4524        // pre-existing configs round-trip byte-stably.
4525        let cfg = PawConfig::default();
4526        let serialized = toml::to_string_pretty(&cfg).expect("serialize");
4527        assert!(
4528            !serialized.contains("worktree_placement"),
4529            "absent placement must not be serialized; got:\n{serialized}"
4530        );
4531    }
4532
4533    #[test]
4534    fn preexisting_config_without_placement_loads_without_error() {
4535        // A v0.7.0 config (no worktree_placement field) must load and resolve
4536        // to sibling.
4537        let prior = "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n";
4538        let cfg: PawConfig = toml::from_str(prior).expect("v0.7.0 config must load");
4539        assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4540    }
4541}