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