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