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}
24
25/// A named preset defining branches and a CLI to use.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Preset {
28    /// Branches to open in this preset.
29    pub branches: Vec<String>,
30    /// CLI to use for all branches in this preset.
31    pub cli: String,
32}
33
34/// Governance document paths.
35///
36/// Each field is a pointer to a user-maintained document or directory that
37/// describes some aspect of the project's governance (ADRs, test strategy,
38/// security checklist, Definition of Done, project constitution).
39///
40/// All fields are optional and stored as raw [`PathBuf`] values. Relative
41/// paths are resolved against the repository root at *use time* by
42/// downstream consumers, not at config-load time. Absolute paths are
43/// preserved as-is. No filesystem existence check is performed during
44/// config-load — pointing at a path that doesn't exist is a runtime
45/// concern, not a parse error.
46///
47/// This struct is storage-only: nothing in `git_paw::config` reads the
48/// referenced documents or enforces any rubric against them. The runtime
49/// consumer lives in the parallel `governance-context` capability.
50#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
51pub struct GovernanceConfig {
52    /// Directory containing ADR files. Project chooses the convention
53    /// (Nygard, MADR, `adr-tools`, custom). git-paw does not dictate one.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub adr: Option<PathBuf>,
56    /// Single Markdown file describing the project's test strategy.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub test_strategy: Option<PathBuf>,
59    /// Single Markdown file containing the project's security checklist.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub security: Option<PathBuf>,
62    /// Single Markdown file containing the project's Definition of Done.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub dod: Option<PathBuf>,
65    /// Single Markdown file containing the project's constitution
66    /// (`Spec Kit`'s `constitution.md` or any project's equivalent). May
67    /// be auto-populated from `.specify/memory/constitution.md` when the
68    /// `SpecKit` backend is active and the user has not set this field
69    /// explicitly.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub constitution: Option<PathBuf>,
72}
73
74/// Spec scanning configuration.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct SpecsConfig {
77    /// Directory containing spec files (relative to repo root).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub dir: Option<String>,
80    /// Spec format type: `"openspec"` or `"markdown"`.
81    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
82    pub spec_type: Option<String>,
83}
84
85/// Session logging configuration.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
87pub struct LoggingConfig {
88    /// Whether session logging is enabled.
89    #[serde(default)]
90    pub enabled: bool,
91}
92
93/// Approval level governing how much autonomy an agent has when operating
94/// on the repository.
95///
96/// The variants are ordered from most conservative to most permissive:
97///
98/// - `Manual` — the agent must ask the user to approve every file write or
99///   shell command. Safest, but slowest.
100/// - `Auto` — the agent may perform routine edits without asking, but still
101///   defers for destructive or privileged operations. This is the default.
102/// - `FullAuto` — the agent is granted full unattended permissions,
103///   bypassing per-action approval. Only appropriate for trusted sandboxes.
104#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "kebab-case")]
106pub enum ApprovalLevel {
107    /// Prompt the user for every write or command.
108    Manual,
109    /// Allow routine edits without prompting, defer for destructive ops.
110    #[default]
111    Auto,
112    /// Grant full unattended permissions (skip approvals entirely).
113    FullAuto,
114}
115
116/// Dashboard configuration.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118pub struct DashboardConfig {
119    /// Whether to show the broker messages panel in the dashboard.
120    #[serde(default)]
121    pub show_message_log: bool,
122}
123
124/// Supervisor mode configuration.
125///
126/// Supervisor mode puts git-paw in front of the agent CLI as a coordinating
127/// layer that can enforce approval policy and run a verification command
128/// after each agent completes a task.
129#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
130pub struct SupervisorConfig {
131    /// Whether supervisor mode is enabled by default for this repo.
132    #[serde(default)]
133    pub enabled: bool,
134    /// Override the CLI used when launching the supervisor (e.g. `"claude"`).
135    /// `None` resolves to the normal CLI selection flow at runtime.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub cli: Option<String>,
138    /// Test command to run after each agent completes (e.g. `"just check"`).
139    /// `None` skips the verification step.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub test_command: Option<String>,
142    /// Pre-stage lint invocation for the five-gate verification workflow.
143    ///
144    /// Drives gate 1's lint sub-step. Example values per common stack:
145    /// `"cargo clippy -- -D warnings"` (Rust), `"npm run lint"` (Node),
146    /// `"ruff check ."` (Python), `"golangci-lint run"` (Go). When `None`,
147    /// the supervisor skill renders the placeholder as `(not configured)`
148    /// and the supervisor agent skips the tooling invocation.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub lint_command: Option<String>,
151    /// Compile-step command when build is distinct from test.
152    ///
153    /// Drives gate 1's compile sub-step. Example values: `"cargo build"`
154    /// (Rust), `"npm run build"` (Node), `"mvn package"` (Java), `"go
155    /// build ./..."` (Go). When `None`, the supervisor skill renders the
156    /// placeholder as `(not configured)` and the supervisor agent skips
157    /// the tooling invocation.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub build_command: Option<String>,
160    /// Documentation-build command for gate 4 (doc audit).
161    ///
162    /// Example values: `"mdbook build docs/"` (`mdBook`), `"sphinx-build"`
163    /// (Sphinx), `"mkdocs build"` (`MkDocs`), `"npx typedoc"` (`TypeDoc`).
164    /// When `None`, the supervisor skill renders the placeholder as
165    /// `(not configured)` and the supervisor agent skips the tooling
166    /// invocation; the manual doc-surface review still applies.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub doc_build_command: Option<String>,
169    /// Spec-validator command for gate 3 (spec audit).
170    ///
171    /// Typically takes a change name as argument; the supervisor agent
172    /// substitutes `{{CHANGE_ID}}` at verification time using the change
173    /// it is currently auditing. Example values: `"openspec validate
174    /// {{CHANGE_ID}} --strict"` (`OpenSpec`). When `None`, the supervisor
175    /// skill renders the placeholder as `(not configured)` and the
176    /// supervisor agent skips the tooling invocation; the manual
177    /// scenario-coverage check still applies.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub spec_validate_command: Option<String>,
180    /// Formatter-check command for gate 1's pre-stage.
181    ///
182    /// Example values: `"cargo fmt --check"` (Rust), `"prettier --check
183    /// ."` (Node), `"gofmt -l ."` (Go), `"black --check ."` (Python).
184    /// When `None`, the supervisor skill renders the placeholder as
185    /// `(not configured)` and the supervisor agent skips the tooling
186    /// invocation.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub fmt_check_command: Option<String>,
189    /// Security-audit tooling for gate 5.
190    ///
191    /// Example values: `"cargo audit"` (Rust), `"npm audit"` (Node),
192    /// `"bandit -r ."` (Python), `"gosec ./..."` (Go). When `None`, the
193    /// supervisor skill renders the placeholder as `(not configured)`
194    /// and the supervisor agent skips the tooling invocation; the manual
195    /// OWASP-category diff review still applies.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub security_audit_command: Option<String>,
198    /// Approval policy applied to agent actions.
199    #[serde(default)]
200    pub agent_approval: ApprovalLevel,
201    /// Auto-approval configuration for safe permission prompts.
202    ///
203    /// When present, the supervisor automatically approves stalled agents
204    /// whose pending command matches an entry in the safe-command whitelist.
205    /// See [`AutoApproveConfig`] for the per-field semantics.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub auto_approve: Option<AutoApproveConfig>,
208    /// Conflict detector configuration.
209    ///
210    /// Drives the broker-internal subsystem that auto-emits
211    /// `agent.feedback` and `agent.question` for forward, in-flight, and
212    /// ownership conflicts between agents. Active only when
213    /// [`SupervisorConfig::enabled`] is `true`; otherwise the detector
214    /// subsystem is not started and no auto-warnings fire.
215    #[serde(default)]
216    pub conflict: ConflictConfig,
217    /// Opt-in flag for the learnings aggregator subsystem (learnings-mode).
218    ///
219    /// When `true` (and `[broker] enabled = true`), the broker starts a
220    /// learnings aggregator that observes the session and appends
221    /// human-readable summaries to `.git-paw/session-learnings.md`. Defaults
222    /// to `false` — pre-v0.5 configs load without producing learnings.
223    #[serde(default)]
224    pub learnings: bool,
225    /// Tuning knobs for the learnings aggregator.
226    ///
227    /// Honoured only when [`Self::learnings`] is `true`. Missing fields fall
228    /// back to [`LearningsConfig::default`]. The TOML table key is
229    /// `[supervisor.learnings_config]` to avoid colliding with the boolean
230    /// `learnings` field.
231    #[serde(default)]
232    pub learnings_config: LearningsConfig,
233    /// Common dev-command allowlist configuration.
234    ///
235    /// Controls whether the supervisor seeds a curated preset of
236    /// dev-loop prefix patterns (`cargo build`, `git commit`, ...) into
237    /// `.claude/settings.json::allowed_bash_prefixes` on session start.
238    /// See [`CommonDevAllowlistConfig`] for field semantics.
239    #[serde(default)]
240    pub common_dev_allowlist: CommonDevAllowlistConfig,
241}
242
243impl SupervisorConfig {
244    /// Borrowed view of the seven gate-command templates suitable for
245    /// passing to [`crate::skills::render`]. Each field maps directly to
246    /// the matching `Option<String>` on this struct.
247    #[must_use]
248    pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
249        crate::skills::GateCommands {
250            test_command: self.test_command.as_deref(),
251            lint_command: self.lint_command.as_deref(),
252            build_command: self.build_command.as_deref(),
253            doc_build_command: self.doc_build_command.as_deref(),
254            spec_validate_command: self.spec_validate_command.as_deref(),
255            fmt_check_command: self.fmt_check_command.as_deref(),
256            security_audit_command: self.security_audit_command.as_deref(),
257        }
258    }
259}
260
261/// Configuration for the common dev-command allowlist preset.
262///
263/// The preset is a curated set of safe, repeatedly-prompted dev-loop
264/// commands (cargo, git, just, mdbook, openspec, find, grep, sed -n)
265/// that the supervisor seeds into Claude's `allowed_bash_prefixes` so
266/// agents do not hit a permission prompt for each variant of these
267/// commands. See `src/supervisor/dev_allowlist.rs` for the preset
268/// constant and the merge implementation.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270pub struct CommonDevAllowlistConfig {
271    /// Whether the dev-allowlist seeder runs on supervisor start.
272    ///
273    /// Defaults to `true` — the v0.5.0 dogfood evidence makes the
274    /// feature most useful when on by default. Opt out with
275    /// `[supervisor.common_dev_allowlist] enabled = false`.
276    #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
277    pub enabled: bool,
278    /// Additional project-specific prefix patterns appended to the
279    /// built-in preset.
280    ///
281    /// Each entry is a raw string consumed by Claude's prefix matcher;
282    /// the seeder does not validate the strings. Duplicates of preset
283    /// entries are silently de-duplicated.
284    #[serde(default)]
285    pub extra: Vec<String>,
286}
287
288impl Default for CommonDevAllowlistConfig {
289    fn default() -> Self {
290        Self {
291            enabled: Self::default_enabled(),
292            extra: Vec::new(),
293        }
294    }
295}
296
297impl CommonDevAllowlistConfig {
298    fn default_enabled() -> bool {
299        true
300    }
301}
302
303/// Tuning knobs for the learnings aggregator.
304///
305/// The aggregator periodically flushes accumulated learnings to
306/// `.git-paw/session-learnings.md` plus one final flush at broker shutdown.
307/// `flush_interval_seconds` controls the periodic cadence; bursts of activity
308/// may flush sooner if the in-memory queue grows past the soft cap.
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
310pub struct LearningsConfig {
311    /// Interval between periodic flushes to disk. Default: `60`.
312    #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
313    pub flush_interval_seconds: u64,
314}
315
316impl Default for LearningsConfig {
317    fn default() -> Self {
318        Self {
319            flush_interval_seconds: Self::default_flush_interval_seconds(),
320        }
321    }
322}
323
324impl LearningsConfig {
325    fn default_flush_interval_seconds() -> u64 {
326        60
327    }
328}
329
330/// Configuration for the broker-internal conflict detector.
331///
332/// The detector observes `agent.intent` and `agent.status` events as they
333/// pass through the publish pipeline and emits `agent.feedback` /
334/// `agent.question` when one of three failure shapes triggers (forward,
335/// in-flight, ownership). All fields have defaults; an entirely absent
336/// `[supervisor.conflict]` section loads [`ConflictConfig::default`].
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct ConflictConfig {
339    /// Window after which an unresolved in-flight conflict escalates to
340    /// the supervisor inbox via `agent.question`. Default: `120`.
341    #[serde(default = "ConflictConfig::default_window_seconds")]
342    pub window_seconds: u64,
343    /// Master switch for forward-conflict warnings. When `false`, no
344    /// `agent.feedback` is emitted for overlapping `agent.intent`
345    /// declarations, but the tracker SHALL still record intents (so
346    /// in-flight and ownership detection remain functional). Default:
347    /// `true`.
348    #[serde(default = "ConflictConfig::default_true")]
349    pub warn_on_intent_overlap: bool,
350    /// Whether ownership violations escalate to the supervisor inbox via
351    /// `agent.question`. The violator-bound `agent.feedback` always fires
352    /// regardless of this flag — only the supervisor follow-up is gated.
353    /// Default: `true`.
354    #[serde(default = "ConflictConfig::default_true")]
355    pub escalate_on_violation: bool,
356}
357
358impl Default for ConflictConfig {
359    fn default() -> Self {
360        Self {
361            window_seconds: Self::default_window_seconds(),
362            warn_on_intent_overlap: true,
363            escalate_on_violation: true,
364        }
365    }
366}
367
368impl ConflictConfig {
369    fn default_window_seconds() -> u64 {
370        120
371    }
372
373    fn default_true() -> bool {
374        true
375    }
376}
377
378/// Coarse-grained policy preset that maps onto a known [`AutoApproveConfig`]
379/// shape.
380///
381/// The presets exist so users do not have to hand-craft a whitelist when
382/// they just want a sensible default for the project. The mapping is:
383///
384/// - `Off` — auto-approval is disabled regardless of other fields.
385/// - `Conservative` — auto-approve `cargo`/`git commit` style commands but
386///   strip `git push` and `curl` from the effective whitelist.
387/// - `Safe` — the built-in default; auto-approve everything in
388///   [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
389#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "kebab-case")]
391pub enum ApprovalLevelPreset {
392    /// Disable auto-approval entirely.
393    Off,
394    /// Approve only the most uncontroversial commands (no push/curl).
395    Conservative,
396    /// Approve every entry in the built-in safe-command list.
397    #[default]
398    Safe,
399}
400
401/// Configuration for the supervisor auto-approval feature.
402///
403/// Auto-approval detects permission prompts in stalled agent panes via
404/// `tmux capture-pane`, classifies the pending command, and dispatches the
405/// `BTab Down Enter` keystroke sequence when the command matches the
406/// whitelist.
407///
408/// Embedded as `Option<AutoApproveConfig>` on [`SupervisorConfig`] so
409/// existing configs without an `[supervisor.auto_approve]` table continue
410/// to round-trip identically.
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412pub struct AutoApproveConfig {
413    /// Master enable flag. When `false`, no detection or approval runs.
414    #[serde(default = "AutoApproveConfig::default_enabled")]
415    pub enabled: bool,
416    /// Project-specific safe-command prefixes appended to the built-in
417    /// defaults from
418    /// [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
419    #[serde(default)]
420    pub safe_commands: Vec<String>,
421    /// Threshold (in seconds) of `last_seen` staleness before an agent in
422    /// `working` status is treated as stalled by the poll loop.
423    #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
424    pub stall_threshold_seconds: u64,
425    /// Coarse policy preset applied on top of the explicit fields.
426    ///
427    /// When the preset is `Off`, [`Self::enabled`] is forced to `false` by
428    /// [`Self::resolved`]. When the preset is `Conservative`, the effective
429    /// whitelist is the built-in defaults minus `git push` and `curl`
430    /// entries.
431    #[serde(default)]
432    pub approval_level: ApprovalLevelPreset,
433}
434
435impl Default for AutoApproveConfig {
436    fn default() -> Self {
437        Self {
438            enabled: Self::default_enabled(),
439            safe_commands: Vec::new(),
440            stall_threshold_seconds: Self::default_stall_threshold_seconds(),
441            approval_level: ApprovalLevelPreset::Safe,
442        }
443    }
444}
445
446impl AutoApproveConfig {
447    /// Minimum stall threshold in seconds. Anything lower is clamped to
448    /// avoid pathological poll loops.
449    pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
450
451    fn default_enabled() -> bool {
452        true
453    }
454
455    fn default_stall_threshold_seconds() -> u64 {
456        30
457    }
458
459    /// Returns a copy of this config with preset rules applied and the
460    /// stall threshold floor enforced.
461    ///
462    /// - When `approval_level == Off`, `enabled` is forced to `false`.
463    /// - When `stall_threshold_seconds < MIN_STALL_THRESHOLD_SECONDS`, the
464    ///   value is clamped and a warning is written to stderr.
465    #[must_use]
466    pub fn resolved(&self) -> Self {
467        let mut out = self.clone();
468        if out.approval_level == ApprovalLevelPreset::Off {
469            out.enabled = false;
470        }
471        if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
472            eprintln!(
473                "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
474                out.stall_threshold_seconds,
475                Self::MIN_STALL_THRESHOLD_SECONDS
476            );
477            out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
478        }
479        out
480    }
481
482    /// Returns the effective whitelist for this config, applying the preset
483    /// to the union of built-in defaults and user-configured `safe_commands`.
484    ///
485    /// - `Off` and `Safe` both return defaults plus configured extras.
486    /// - `Conservative` returns the same union with `git push` and any
487    ///   `curl` entries filtered out.
488    #[must_use]
489    pub fn effective_whitelist(&self) -> Vec<String> {
490        let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
491            .iter()
492            .map(|s| (*s).to_string())
493            .collect();
494        for extra in &self.safe_commands {
495            if !out.iter().any(|e| e == extra) {
496                out.push(extra.clone());
497            }
498        }
499        if self.approval_level == ApprovalLevelPreset::Conservative {
500            out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
501        }
502        out
503    }
504}
505
506/// Returns the CLI-specific permission flag for `cli` at the given approval
507/// `level`, or an empty string if the combination has no mapped flag.
508///
509/// # Examples
510///
511/// ```
512/// use git_paw::config::{approval_flags, ApprovalLevel};
513///
514/// assert_eq!(
515///     approval_flags("claude", &ApprovalLevel::FullAuto),
516///     "--dangerously-skip-permissions",
517/// );
518/// assert_eq!(
519///     approval_flags("codex", &ApprovalLevel::Auto),
520///     "--approval-mode=auto-edit",
521/// );
522/// assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
523/// assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
524/// ```
525#[must_use]
526pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
527    match (cli, level) {
528        ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
529        ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
530        ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
531        _ => "",
532    }
533}
534
535/// HTTP broker configuration for agent coordination.
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
537pub struct BrokerConfig {
538    /// Whether the broker is enabled.
539    #[serde(default)]
540    pub enabled: bool,
541    /// TCP port the broker listens on.
542    #[serde(default = "BrokerConfig::default_port")]
543    pub port: u16,
544    /// Bind address for the broker.
545    #[serde(default = "BrokerConfig::default_bind")]
546    pub bind: String,
547}
548
549impl Default for BrokerConfig {
550    fn default() -> Self {
551        Self {
552            enabled: false,
553            port: 9119,
554            bind: "127.0.0.1".to_string(),
555        }
556    }
557}
558
559impl BrokerConfig {
560    /// Returns the full URL for the broker endpoint.
561    pub fn url(&self) -> String {
562        format!("http://{}:{}", self.bind, self.port)
563    }
564
565    fn default_port() -> u16 {
566        9119
567    }
568
569    fn default_bind() -> String {
570        "127.0.0.1".to_string()
571    }
572}
573
574/// Top-level git-paw configuration.
575///
576/// All fields are optional — absent config files produce empty defaults.
577#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
578pub struct PawConfig {
579    /// Default CLI to use when none is specified.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub default_cli: Option<String>,
582
583    /// Default CLI for `--from-specs` (bypasses picker when set).
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub default_spec_cli: Option<String>,
586
587    /// Prefix for spec-derived branch names (default: `"spec/"`).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub branch_prefix: Option<String>,
590
591    /// Whether to enable tmux mouse mode for sessions.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub mouse: Option<bool>,
594
595    /// Custom CLI definitions keyed by name.
596    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
597    pub clis: HashMap<String, CustomCli>,
598
599    /// Named presets keyed by name.
600    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
601    pub presets: HashMap<String, Preset>,
602
603    /// Spec scanning configuration.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub specs: Option<SpecsConfig>,
606
607    /// Session logging configuration.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub logging: Option<LoggingConfig>,
610
611    /// Dashboard configuration.
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub dashboard: Option<DashboardConfig>,
614
615    /// HTTP broker configuration.
616    #[serde(default)]
617    pub broker: BrokerConfig,
618
619    /// Supervisor mode configuration.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub supervisor: Option<SupervisorConfig>,
622
623    /// Governance document path pointers.
624    ///
625    /// All sub-fields are optional. Absence is equivalent to an empty
626    /// `[governance]` section; v0.4 configs (no `[governance]` at all) load
627    /// with `GovernanceConfig::default()` here.
628    #[serde(default)]
629    pub governance: GovernanceConfig,
630}
631
632impl PawConfig {
633    /// Returns a new config that merges `overlay` on top of `self`.
634    ///
635    /// Scalar fields from `overlay` take precedence when present.
636    /// Map fields are merged with `overlay` entries winning on key collisions.
637    #[must_use]
638    pub fn merged_with(&self, overlay: &Self) -> Self {
639        let mut clis = self.clis.clone();
640        for (k, v) in &overlay.clis {
641            clis.insert(k.clone(), v.clone());
642        }
643
644        let mut presets = self.presets.clone();
645        for (k, v) in &overlay.presets {
646            presets.insert(k.clone(), v.clone());
647        }
648
649        Self {
650            default_cli: overlay
651                .default_cli
652                .clone()
653                .or_else(|| self.default_cli.clone()),
654            default_spec_cli: overlay
655                .default_spec_cli
656                .clone()
657                .or_else(|| self.default_spec_cli.clone()),
658            branch_prefix: overlay
659                .branch_prefix
660                .clone()
661                .or_else(|| self.branch_prefix.clone()),
662            mouse: overlay.mouse.or(self.mouse),
663            clis,
664            presets,
665            specs: overlay.specs.clone().or_else(|| self.specs.clone()),
666            logging: overlay.logging.clone().or_else(|| self.logging.clone()),
667            dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
668            broker: if overlay.broker == BrokerConfig::default() {
669                self.broker.clone()
670            } else {
671                overlay.broker.clone()
672            },
673            supervisor: overlay
674                .supervisor
675                .clone()
676                .or_else(|| self.supervisor.clone()),
677            governance: GovernanceConfig {
678                adr: overlay
679                    .governance
680                    .adr
681                    .clone()
682                    .or_else(|| self.governance.adr.clone()),
683                test_strategy: overlay
684                    .governance
685                    .test_strategy
686                    .clone()
687                    .or_else(|| self.governance.test_strategy.clone()),
688                security: overlay
689                    .governance
690                    .security
691                    .clone()
692                    .or_else(|| self.governance.security.clone()),
693                dod: overlay
694                    .governance
695                    .dod
696                    .clone()
697                    .or_else(|| self.governance.dod.clone()),
698                constitution: overlay
699                    .governance
700                    .constitution
701                    .clone()
702                    .or_else(|| self.governance.constitution.clone()),
703            },
704        }
705    }
706
707    /// Returns a preset by name, if it exists.
708    pub fn get_preset(&self, name: &str) -> Option<&Preset> {
709        self.presets.get(name)
710    }
711
712    /// Returns the dashboard configuration, if it exists.
713    pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
714        self.dashboard.as_ref()
715    }
716}
717
718/// Returns the path to the global config file (`~/.config/git-paw/config.toml`).
719pub fn global_config_path() -> Result<PathBuf, PawError> {
720    crate::dirs::config_dir()
721        .map(|d| d.join("git-paw").join("config.toml"))
722        .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
723}
724
725/// Returns the path to a repo-level config file (`.git-paw/config.toml`).
726pub fn repo_config_path(repo_root: &Path) -> PathBuf {
727    repo_root.join(".git-paw").join("config.toml")
728}
729
730/// Loads a [`PawConfig`] from a TOML file, returning `Ok(None)` if the file does not exist.
731fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
732    match fs::read_to_string(path) {
733        Ok(contents) => {
734            let config: PawConfig = toml::from_str(&contents)
735                .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
736            Ok(Some(config))
737        }
738        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
739        Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
740    }
741}
742
743/// Loads only the repo-level configuration (`.git-paw/config.toml`).
744///
745/// Returns defaults if the file does not exist. Useful when you need to
746/// update and save repo-level settings without clobbering global values.
747///
748/// Applies post-deserialise auto-wiring for governance documents (see
749/// [`auto_wire_governance`]).
750pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
751    let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
752    auto_wire_governance(&mut config, repo_root);
753    Ok(config)
754}
755
756/// Populates `config.governance.constitution` from
757/// `git_paw::specs::speckit::detect_constitution` when:
758///
759/// 1. The user has not set `governance.constitution` explicitly
760///    (i.e. it is `None` after TOML deserialisation), AND
761/// 2. A `[specs]` section is present, AND
762/// 3. `specs.type == "speckit"`.
763///
764/// Explicit user values always win — even if the explicit value points
765/// at a path that does not exist. The check is `is_some()`, not
766/// `is_some_and(|p| p.exists())`, so an empty-string or invalid path
767/// still suppresses auto-wiring. This lets users disable the auto-wiring
768/// without deleting the constitution slot.
769///
770/// This function is intentionally a no-op when the `SpecKit` backend
771/// is not active. It is also a no-op when the configured `specs.dir`'s
772/// parent does not contain `memory/constitution.md`.
773fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
774    if config.governance.constitution.is_some() {
775        return;
776    }
777    let Some(specs_cfg) = config.specs.as_ref() else {
778        return;
779    };
780    let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
781        return;
782    };
783    if spec_type != "speckit" {
784        return;
785    }
786    let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
787    let specs_dir = repo_root.join(dir);
788    if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
789        config.governance.constitution = Some(detected);
790    }
791}
792
793/// Loads the merged configuration for a repository.
794///
795/// Reads the user-level (global) config and the per-repo config, merging
796/// them with repo settings taking precedence. Returns defaults if neither
797/// file exists.
798///
799/// # Parameters
800///
801/// - `repo_root` — the repository root whose `.git-paw/config.toml` is the
802///   repo-level config.
803/// - `user_config_path` — controls which file is read as the user-level
804///   (global) config:
805///   - `None` resolves the user-level path via [`global_config_path`]
806///     (platform default: `crate::dirs::config_dir().join("git-paw/config.toml")`).
807///     This preserves v0.4 production behaviour and is what every internal
808///     caller passes.
809///   - `Some(p)` pins the user-level read to `p`. If `p` does not exist on
810///     disk, the user-level side of the merge is the default `PawConfig`,
811///     exactly as if no file existed at the platform-default path. This is
812///     the discoverable test-isolation hook — pass an unused `TempDir`-rooted
813///     path so the dev machine's real user-level config cannot leak into
814///     the merged result.
815///
816/// See [`load_config_from`] for the lower-level primitive that takes both
817/// paths explicitly (without the `Option` ergonomics).
818pub fn load_config(
819    repo_root: &Path,
820    user_config_path: Option<&Path>,
821) -> Result<PawConfig, PawError> {
822    let global_path = match user_config_path {
823        Some(p) => p.to_path_buf(),
824        None => global_config_path()?,
825    };
826    load_config_from(&global_path, repo_root)
827}
828
829/// Loads merged config from an explicit global path and repo root.
830///
831/// Applies post-merge auto-wiring for governance documents (see
832/// [`auto_wire_governance`]).
833pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
834    let global = load_config_file(global_path)?.unwrap_or_default();
835    let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
836    let mut merged = global.merged_with(&repo);
837    auto_wire_governance(&mut merged, repo_root);
838    Ok(merged)
839}
840
841/// Saves a [`PawConfig`] to the repo-level config file (`.git-paw/config.toml`).
842pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
843    save_config_to(&repo_config_path(repo_root), config)
844}
845
846/// Writes a [`PawConfig`] to a TOML file atomically (temp file + rename).
847fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
848    let dir = path
849        .parent()
850        .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
851    fs::create_dir_all(dir)
852        .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
853
854    let contents =
855        toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
856
857    // Atomic write: temp file + rename
858    let tmp = path.with_extension("toml.tmp");
859    fs::write(&tmp, &contents)
860        .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
861    fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
862
863    Ok(())
864}
865
866/// Adds a custom CLI to the global config.
867///
868/// If `command` is not an absolute path, it is resolved via PATH using `which`.
869pub fn add_custom_cli(
870    name: &str,
871    command: &str,
872    display_name: Option<&str>,
873) -> Result<(), PawError> {
874    add_custom_cli_to(&global_config_path()?, name, command, display_name)
875}
876
877/// Adds a custom CLI to the config at the given path.
878///
879/// If `command` is not an absolute path, it is resolved via PATH using `which`.
880pub fn add_custom_cli_to(
881    config_path: &Path,
882    name: &str,
883    command: &str,
884    display_name: Option<&str>,
885) -> Result<(), PawError> {
886    let resolved_command = if Path::new(command).is_absolute() {
887        command.to_string()
888    } else {
889        which::which(command)
890            .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
891            .to_string_lossy()
892            .into_owned()
893    };
894
895    let mut config = load_config_file(config_path)?.unwrap_or_default();
896
897    config.clis.insert(
898        name.to_string(),
899        CustomCli {
900            command: resolved_command,
901            display_name: display_name.map(String::from),
902        },
903    );
904
905    save_config_to(config_path, &config)
906}
907
908/// Returns a default `config.toml` string with sensible defaults and
909/// commented-out v0.2.0 fields for discoverability.
910pub fn generate_default_config() -> String {
911    r#"# git-paw configuration
912# See https://github.com/bearicorn/git-paw for documentation.
913
914# Pre-select a CLI in the interactive picker (user can still change).
915# Omit to show the full picker with no default.
916# default_cli = ""
917
918# Enable tmux mouse mode for sessions (default: true).
919# mouse = true
920
921# Bypass the CLI picker entirely for --from-specs mode.
922# Omit to prompt or use per-spec paw_cli fields.
923# default_spec_cli = ""
924
925# Prefix for spec-derived branch names (default: "spec/" ).
926# branch_prefix = "spec/"
927
928# Dashboard message log configuration.
929# [dashboard]
930# show_message_log = false
931
932# Spec scanning configuration.
933# [specs]
934# dir = "specs"
935#
936# OpenSpec format (directory-based, default):
937# type = "openspec"
938#
939# Markdown format (frontmatter-based):
940# type = "markdown"
941# Each .md file uses YAML frontmatter fields:
942#   paw_status  — "pending" | "done" | "in-progress" (required)
943#   paw_branch  — branch name suffix (optional, falls back to filename)
944#   paw_cli     — CLI override for this spec (optional)
945
946# Session logging configuration.
947# [logging]
948# enabled = false
949
950# HTTP broker for agent coordination (requires --broker flag on start).
951# [broker]
952# enabled = true
953# port = 9119
954# bind = "127.0.0.1"
955
956# Supervisor mode — git-paw acts as a coordinating layer in front of the
957# agent CLI, enforcing approval policy and running configured gate
958# commands during the five-gate verification workflow.
959#
960# Gate command templates feed the supervisor skill's five gates: gate 1
961# Testing (fmt_check / lint / build / test), gate 3 Spec audit
962# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
963# (security_audit). When a key is omitted, the matching placeholder
964# renders as `(not configured)` in the supervisor skill and the agent
965# skips that tooling step (the gate's manual review still applies).
966# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
967# supervisor agent at verification time with the change name.
968# [supervisor]
969# enabled = true
970# cli = "claude"
971# test_command = "just check"                                  # or: "cargo test", "npm test", "pytest"
972# lint_command = "cargo clippy -- -D warnings"                 # or: "npm run lint", "ruff check .", "golangci-lint run"
973# build_command = "cargo build"                                # or: "npm run build", "mvn package", "go build ./..."
974# fmt_check_command = "cargo fmt --check"                      # or: "prettier --check .", "gofmt -l ."
975# doc_build_command = "mdbook build docs/"                     # or: "sphinx-build", "mkdocs build"
976# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict"  # OpenSpec only
977# security_audit_command = "cargo audit"                       # or: "npm audit", "bandit -r ."
978# agent_approval = "auto"  # one of: "manual", "auto", "full-auto"
979#
980# Conflict detector tuning. Active only when supervisor mode is enabled.
981# [supervisor.conflict]
982# window_seconds = 120          # escalate unresolved in-flight conflicts after this many seconds
983# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
984# escalate_on_violation = true  # also publish agent.question to supervisor on ownership violations
985
986# Common dev-command allowlist. When supervisor mode starts a session,
987# git-paw seeds .claude/settings.json::allowed_bash_prefixes with a
988# curated preset (cargo, git, just, mdbook, openspec, find, grep, sed -n)
989# so agents do not hit a permission prompt for each variant. Opt out by
990# setting enabled = false; extend with project-specific prefixes via extra.
991# [supervisor.common_dev_allowlist]
992# enabled = true
993# extra = ["pnpm test", "deno fmt"]
994
995# Custom CLI definitions.
996# [clis.my-agent]
997# command = "/usr/local/bin/my-agent"
998# display_name = "My Agent"
999
1000# Named presets for quick launches.
1001# [presets.my-preset]
1002# branches = ["feat/api", "fix/db"]
1003# cli = ""
1004"#
1005    .to_string()
1006}
1007
1008/// Removes a custom CLI from the global config.
1009///
1010/// Returns `PawError::CliNotFound` if the name is not present in the config.
1011pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1012    remove_custom_cli_from(&global_config_path()?, name)
1013}
1014
1015/// Removes a custom CLI from the config at the given path.
1016///
1017/// Returns `PawError::CliNotFound` if the name is not present in the config.
1018pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1019    let mut config = load_config_file(config_path)?.unwrap_or_default();
1020
1021    if config.clis.remove(name).is_none() {
1022        return Err(PawError::CliNotFound(name.to_string()));
1023    }
1024
1025    save_config_to(config_path, &config)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use tempfile::TempDir;
1032
1033    fn write_file(path: &Path, content: &str) {
1034        if let Some(parent) = path.parent() {
1035            fs::create_dir_all(parent).unwrap();
1036        }
1037        fs::write(path, content).unwrap();
1038    }
1039
1040    // --- Parsing behavior ---
1041
1042    #[test]
1043    fn parses_config_with_all_fields() {
1044        let tmp = TempDir::new().unwrap();
1045        let path = tmp.path().join("config.toml");
1046        write_file(
1047            &path,
1048            r#"
1049default_cli = "claude"
1050mouse = false
1051default_spec_cli = "gemini"
1052branch_prefix = "spec/"
1053
1054[clis.my-agent]
1055command = "/usr/local/bin/my-agent"
1056display_name = "My Agent"
1057
1058[clis.local-llm]
1059command = "ollama-code"
1060
1061[presets.backend]
1062branches = ["feature/api", "fix/db"]
1063cli = "claude"
1064
1065[specs]
1066dir = "my-specs"
1067type = "openspec"
1068
1069[logging]
1070enabled = true
1071"#,
1072        );
1073
1074        let config = load_config_file(&path).unwrap().unwrap();
1075        assert_eq!(config.default_cli.as_deref(), Some("claude"));
1076        assert_eq!(config.mouse, Some(false));
1077        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1078        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1079        assert_eq!(config.clis.len(), 2);
1080        assert_eq!(
1081            config.clis["my-agent"].display_name.as_deref(),
1082            Some("My Agent")
1083        );
1084        assert_eq!(config.clis["local-llm"].command, "ollama-code");
1085        assert_eq!(config.presets["backend"].cli, "claude");
1086        assert_eq!(
1087            config.presets["backend"].branches,
1088            vec!["feature/api", "fix/db"]
1089        );
1090        let specs = config.specs.unwrap();
1091        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1092        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1093        let logging = config.logging.unwrap();
1094        assert!(logging.enabled);
1095    }
1096
1097    #[test]
1098    fn all_fields_are_optional() {
1099        let tmp = TempDir::new().unwrap();
1100        let path = tmp.path().join("config.toml");
1101        write_file(&path, "default_cli = \"gemini\"\n");
1102
1103        let config = load_config_file(&path).unwrap().unwrap();
1104        assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1105        assert_eq!(config.mouse, None);
1106        assert!(config.clis.is_empty());
1107        assert!(config.presets.is_empty());
1108    }
1109
1110    #[test]
1111    fn returns_defaults_when_no_files_exist() {
1112        let tmp = TempDir::new().unwrap();
1113        let global_path = tmp.path().join("nonexistent").join("config.toml");
1114        let repo_root = tmp.path().join("repo");
1115        fs::create_dir_all(&repo_root).unwrap();
1116
1117        let config = load_config_from(&global_path, &repo_root).unwrap();
1118        assert_eq!(config.default_cli, None);
1119        assert_eq!(config.mouse, None);
1120        assert!(config.clis.is_empty());
1121        assert!(config.presets.is_empty());
1122    }
1123
1124    #[test]
1125    fn reports_error_for_invalid_toml() {
1126        let tmp = TempDir::new().unwrap();
1127        let path = tmp.path().join("bad.toml");
1128        write_file(&path, "this is not [valid toml");
1129
1130        let err = load_config_file(&path).unwrap_err();
1131        assert!(err.to_string().contains("bad.toml"));
1132    }
1133
1134    // --- Merge behavior (through file I/O) ---
1135
1136    #[test]
1137    fn repo_config_overrides_global_scalars() {
1138        let tmp = TempDir::new().unwrap();
1139        let global_path = tmp.path().join("global").join("config.toml");
1140        let repo_root = tmp.path().join("repo");
1141        fs::create_dir_all(&repo_root).unwrap();
1142
1143        write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1144        write_file(
1145            &repo_config_path(&repo_root),
1146            "default_cli = \"gemini\"\n", // mouse intentionally absent
1147        );
1148
1149        let config = load_config_from(&global_path, &repo_root).unwrap();
1150        assert_eq!(config.default_cli.as_deref(), Some("gemini")); // repo wins
1151        assert_eq!(config.mouse, Some(true)); // global preserved when repo absent
1152    }
1153
1154    #[test]
1155    fn repo_config_merges_cli_maps() {
1156        let tmp = TempDir::new().unwrap();
1157        let global_path = tmp.path().join("global").join("config.toml");
1158        let repo_root = tmp.path().join("repo");
1159        fs::create_dir_all(&repo_root).unwrap();
1160
1161        write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1162        write_file(
1163            &repo_config_path(&repo_root),
1164            "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1165        );
1166
1167        let config = load_config_from(&global_path, &repo_root).unwrap();
1168        assert_eq!(config.clis.len(), 2);
1169        assert!(config.clis.contains_key("agent-a"));
1170        assert!(config.clis.contains_key("agent-b"));
1171    }
1172
1173    #[test]
1174    fn repo_cli_overrides_global_cli_with_same_name() {
1175        let tmp = TempDir::new().unwrap();
1176        let global_path = tmp.path().join("global").join("config.toml");
1177        let repo_root = tmp.path().join("repo");
1178        fs::create_dir_all(&repo_root).unwrap();
1179
1180        write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1181        write_file(
1182            &repo_config_path(&repo_root),
1183            "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1184        );
1185
1186        let config = load_config_from(&global_path, &repo_root).unwrap();
1187        assert_eq!(config.clis["my-agent"].command, "/new/path");
1188        assert_eq!(
1189            config.clis["my-agent"].display_name.as_deref(),
1190            Some("Overridden")
1191        );
1192    }
1193
1194    #[test]
1195    fn load_config_from_reads_global_file_when_no_repo() {
1196        let tmp = TempDir::new().unwrap();
1197        let global_path = tmp.path().join("global").join("config.toml");
1198        let repo_root = tmp.path().join("repo");
1199        fs::create_dir_all(&repo_root).unwrap();
1200
1201        write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1202        // No .git-paw/config.toml in repo_root
1203
1204        let config = load_config_from(&global_path, &repo_root).unwrap();
1205        assert_eq!(config.default_cli.as_deref(), Some("claude"));
1206        assert_eq!(config.mouse, Some(false));
1207    }
1208
1209    #[test]
1210    fn load_config_from_reads_repo_file_when_no_global() {
1211        let tmp = TempDir::new().unwrap();
1212        let global_path = tmp.path().join("nonexistent").join("config.toml");
1213        let repo_root = tmp.path().join("repo");
1214        fs::create_dir_all(&repo_root).unwrap();
1215
1216        write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1217
1218        let config = load_config_from(&global_path, &repo_root).unwrap();
1219        assert_eq!(config.default_cli.as_deref(), Some("codex"));
1220    }
1221
1222    // --- Preset behavior ---
1223
1224    #[test]
1225    fn preset_accessible_by_name() {
1226        let tmp = TempDir::new().unwrap();
1227        let global_path = tmp.path().join("global").join("config.toml");
1228        let repo_root = tmp.path().join("repo");
1229        fs::create_dir_all(&repo_root).unwrap();
1230
1231        write_file(
1232            &repo_config_path(&repo_root),
1233            "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1234        );
1235
1236        let config = load_config_from(&global_path, &repo_root).unwrap();
1237        let preset = config.get_preset("backend").unwrap();
1238        assert_eq!(preset.cli, "claude");
1239        assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1240    }
1241
1242    #[test]
1243    fn preset_returns_none_when_not_in_config() {
1244        let tmp = TempDir::new().unwrap();
1245        let global_path = tmp.path().join("config.toml");
1246        write_file(&global_path, "default_cli = \"claude\"\n");
1247
1248        let config = load_config_file(&global_path).unwrap().unwrap();
1249        assert!(config.get_preset("nonexistent").is_none());
1250    }
1251
1252    // --- add_custom_cli behavior ---
1253
1254    #[test]
1255    fn add_cli_writes_to_config_file() {
1256        let tmp = TempDir::new().unwrap();
1257        let config_path = tmp.path().join("git-paw").join("config.toml");
1258
1259        // Add a CLI with an absolute path (no PATH resolution needed)
1260        add_custom_cli_to(
1261            &config_path,
1262            "my-agent",
1263            "/usr/local/bin/my-agent",
1264            Some("My Agent"),
1265        )
1266        .unwrap();
1267
1268        // Verify by loading the file back
1269        let config = load_config_file(&config_path).unwrap().unwrap();
1270        assert_eq!(config.clis.len(), 1);
1271        assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1272        assert_eq!(
1273            config.clis["my-agent"].display_name.as_deref(),
1274            Some("My Agent")
1275        );
1276    }
1277
1278    #[test]
1279    fn add_cli_preserves_existing_entries() {
1280        let tmp = TempDir::new().unwrap();
1281        let config_path = tmp.path().join("git-paw").join("config.toml");
1282
1283        add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1284        add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1285
1286        let config = load_config_file(&config_path).unwrap().unwrap();
1287        assert_eq!(config.clis.len(), 2);
1288        assert!(config.clis.contains_key("first"));
1289        assert!(config.clis.contains_key("second"));
1290    }
1291
1292    #[test]
1293    fn add_cli_errors_when_command_not_on_path() {
1294        let tmp = TempDir::new().unwrap();
1295        let config_path = tmp.path().join("config.toml");
1296
1297        let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1298            .unwrap_err();
1299        assert!(err.to_string().contains("not found on PATH"));
1300    }
1301
1302    // --- remove_custom_cli behavior ---
1303
1304    #[test]
1305    fn remove_cli_deletes_entry_from_config_file() {
1306        let tmp = TempDir::new().unwrap();
1307        let config_path = tmp.path().join("git-paw").join("config.toml");
1308
1309        // Set up: add two CLIs
1310        add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1311        add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1312
1313        // Act: remove one
1314        remove_custom_cli_from(&config_path, "remove-me").unwrap();
1315
1316        // Verify: only the kept CLI remains
1317        let config = load_config_file(&config_path).unwrap().unwrap();
1318        assert_eq!(config.clis.len(), 1);
1319        assert!(config.clis.contains_key("keep-me"));
1320        assert!(!config.clis.contains_key("remove-me"));
1321    }
1322
1323    #[test]
1324    fn remove_nonexistent_cli_returns_cli_not_found_error() {
1325        let tmp = TempDir::new().unwrap();
1326        let config_path = tmp.path().join("config.toml");
1327        // Empty config file
1328        write_file(&config_path, "");
1329
1330        let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1331        match err {
1332            PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1333            other => panic!("expected CliNotFound, got: {other}"),
1334        }
1335    }
1336
1337    #[test]
1338    fn remove_cli_from_empty_config_returns_error() {
1339        let tmp = TempDir::new().unwrap();
1340        let config_path = tmp.path().join("config.toml");
1341        // No file at all
1342
1343        let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1344        match err {
1345            PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1346            other => panic!("expected CliNotFound, got: {other}"),
1347        }
1348    }
1349
1350    // --- Round-trip: config survives write + read ---
1351
1352    // --- default_spec_cli behavior ---
1353
1354    #[test]
1355    fn parses_default_spec_cli_when_present() {
1356        let tmp = TempDir::new().unwrap();
1357        let path = tmp.path().join("config.toml");
1358        write_file(&path, "default_spec_cli = \"claude\"\n");
1359
1360        let config = load_config_file(&path).unwrap().unwrap();
1361        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1362    }
1363
1364    #[test]
1365    fn default_spec_cli_defaults_to_none() {
1366        let tmp = TempDir::new().unwrap();
1367        let path = tmp.path().join("config.toml");
1368        write_file(&path, "default_cli = \"claude\"\n");
1369
1370        let config = load_config_file(&path).unwrap().unwrap();
1371        assert_eq!(config.default_spec_cli, None);
1372    }
1373
1374    #[test]
1375    fn repo_overrides_global_default_spec_cli() {
1376        let tmp = TempDir::new().unwrap();
1377        let global_path = tmp.path().join("global").join("config.toml");
1378        let repo_root = tmp.path().join("repo");
1379        fs::create_dir_all(&repo_root).unwrap();
1380
1381        write_file(&global_path, "default_spec_cli = \"claude\"\n");
1382        write_file(
1383            &repo_config_path(&repo_root),
1384            "default_spec_cli = \"gemini\"\n",
1385        );
1386
1387        let config = load_config_from(&global_path, &repo_root).unwrap();
1388        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1389    }
1390
1391    #[test]
1392    fn global_default_spec_cli_preserved_when_repo_absent() {
1393        let tmp = TempDir::new().unwrap();
1394        let global_path = tmp.path().join("global").join("config.toml");
1395        let repo_root = tmp.path().join("repo");
1396        fs::create_dir_all(&repo_root).unwrap();
1397
1398        write_file(&global_path, "default_spec_cli = \"claude\"\n");
1399
1400        let config = load_config_from(&global_path, &repo_root).unwrap();
1401        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1402    }
1403
1404    // --- Round-trip: config survives write + read ---
1405
1406    #[test]
1407    fn config_survives_save_and_load() {
1408        let tmp = TempDir::new().unwrap();
1409        let config_path = tmp.path().join("config.toml");
1410
1411        let original = PawConfig {
1412            default_cli: Some("claude".into()),
1413            default_spec_cli: None,
1414            branch_prefix: None,
1415            mouse: Some(true),
1416            clis: HashMap::from([(
1417                "test".into(),
1418                CustomCli {
1419                    command: "/bin/test".into(),
1420                    display_name: Some("Test CLI".into()),
1421                },
1422            )]),
1423            presets: HashMap::from([(
1424                "dev".into(),
1425                Preset {
1426                    branches: vec!["main".into()],
1427                    cli: "claude".into(),
1428                },
1429            )]),
1430            specs: None,
1431            logging: None,
1432            dashboard: None,
1433            broker: BrokerConfig::default(),
1434            supervisor: None,
1435            governance: GovernanceConfig::default(),
1436        };
1437
1438        save_config_to(&config_path, &original).unwrap();
1439        let loaded = load_config_file(&config_path).unwrap().unwrap();
1440        assert_eq!(original, loaded);
1441    }
1442
1443    // --- Gap #1: Parse [specs] section with populated fields ---
1444
1445    #[test]
1446    fn parses_specs_section_with_populated_fields() {
1447        let tmp = TempDir::new().unwrap();
1448        let path = tmp.path().join("config.toml");
1449        write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
1450
1451        let config = load_config_file(&path).unwrap().unwrap();
1452        let specs = config.specs.unwrap();
1453        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1454        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1455    }
1456
1457    // --- Gap #2: Parse [logging] section with enabled ---
1458
1459    #[test]
1460    fn parses_logging_section_with_enabled() {
1461        let tmp = TempDir::new().unwrap();
1462        let path = tmp.path().join("config.toml");
1463        write_file(&path, "[logging]\nenabled = true\n");
1464
1465        let config = load_config_file(&path).unwrap().unwrap();
1466        let logging = config.logging.unwrap();
1467        assert!(logging.enabled);
1468    }
1469
1470    // --- Gap #3: Round-trip with specs and logging populated ---
1471
1472    #[test]
1473    fn round_trip_with_specs_and_logging() {
1474        let tmp = TempDir::new().unwrap();
1475        let config_path = tmp.path().join("config.toml");
1476
1477        let original = PawConfig {
1478            specs: Some(SpecsConfig {
1479                dir: Some("specs".into()),
1480                spec_type: Some("openspec".into()),
1481            }),
1482            logging: Some(LoggingConfig { enabled: true }),
1483            ..Default::default()
1484        };
1485
1486        save_config_to(&config_path, &original).unwrap();
1487        let loaded = load_config_file(&config_path).unwrap().unwrap();
1488        assert_eq!(original, loaded);
1489        assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
1490        assert!(loaded.logging.unwrap().enabled);
1491    }
1492
1493    // --- Gap #4: Generated config is valid TOML ---
1494
1495    #[test]
1496    fn generated_default_config_is_valid_toml() {
1497        let raw = generate_default_config();
1498        let stripped: String = raw
1499            .lines()
1500            .filter(|line| !line.trim_start().starts_with('#'))
1501            .collect::<Vec<&str>>()
1502            .join("\n");
1503
1504        let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
1505        assert!(
1506            parsed.is_ok(),
1507            "generated config with comments stripped should be valid TOML, got: {:?}",
1508            parsed.unwrap_err()
1509        );
1510    }
1511
1512    // --- Gap #5: branch_prefix merge ---
1513
1514    #[test]
1515    fn branch_prefix_repo_overrides_global() {
1516        let tmp = TempDir::new().unwrap();
1517        let global_path = tmp.path().join("global").join("config.toml");
1518        let repo_root = tmp.path().join("repo");
1519        fs::create_dir_all(&repo_root).unwrap();
1520
1521        write_file(&global_path, "branch_prefix = \"feat/\"\n");
1522        write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
1523
1524        let config = load_config_from(&global_path, &repo_root).unwrap();
1525        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1526    }
1527
1528    #[test]
1529    fn generated_default_config_contains_commented_examples() {
1530        let output = generate_default_config();
1531        assert!(
1532            output.contains("default_spec_cli"),
1533            "should contain default_spec_cli"
1534        );
1535        assert!(
1536            output.contains("branch_prefix"),
1537            "should contain branch_prefix"
1538        );
1539        assert!(output.contains("[specs]"), "should contain [specs]");
1540        assert!(output.contains("[logging]"), "should contain [logging]");
1541        assert!(output.contains("[broker]"), "should contain [broker]");
1542    }
1543
1544    // --- BrokerConfig ---
1545
1546    #[test]
1547    fn broker_config_defaults() {
1548        let config = BrokerConfig::default();
1549        assert!(!config.enabled);
1550        assert_eq!(config.port, 9119);
1551        assert_eq!(config.bind, "127.0.0.1");
1552    }
1553
1554    #[test]
1555    fn broker_config_url() {
1556        let config = BrokerConfig::default();
1557        assert_eq!(config.url(), "http://127.0.0.1:9119");
1558
1559        let custom = BrokerConfig {
1560            enabled: true,
1561            port: 8080,
1562            bind: "0.0.0.0".to_string(),
1563        };
1564        assert_eq!(custom.url(), "http://0.0.0.0:8080");
1565    }
1566
1567    #[test]
1568    fn empty_config_gets_broker_defaults() {
1569        let tmp = TempDir::new().unwrap();
1570        let path = tmp.path().join("config.toml");
1571        write_file(&path, "");
1572
1573        let config = load_config_file(&path).unwrap().unwrap();
1574        assert!(!config.broker.enabled);
1575        assert_eq!(config.broker.port, 9119);
1576        assert_eq!(config.broker.bind, "127.0.0.1");
1577    }
1578
1579    #[test]
1580    fn parses_full_broker_section() {
1581        let tmp = TempDir::new().unwrap();
1582        let path = tmp.path().join("config.toml");
1583        write_file(
1584            &path,
1585            "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
1586        );
1587
1588        let config = load_config_file(&path).unwrap().unwrap();
1589        assert!(config.broker.enabled);
1590        assert_eq!(config.broker.port, 8080);
1591        assert_eq!(config.broker.bind, "0.0.0.0");
1592    }
1593
1594    #[test]
1595    fn parses_partial_broker_section() {
1596        let tmp = TempDir::new().unwrap();
1597        let path = tmp.path().join("config.toml");
1598        write_file(&path, "[broker]\nenabled = true\n");
1599
1600        let config = load_config_file(&path).unwrap().unwrap();
1601        assert!(config.broker.enabled);
1602        assert_eq!(config.broker.port, 9119);
1603        assert_eq!(config.broker.bind, "127.0.0.1");
1604    }
1605
1606    // --- SupervisorConfig ---
1607
1608    #[test]
1609    fn supervisor_is_none_when_section_absent() {
1610        let tmp = TempDir::new().unwrap();
1611        let path = tmp.path().join("config.toml");
1612        write_file(&path, "default_cli = \"claude\"\n");
1613
1614        let config = load_config_file(&path).unwrap().unwrap();
1615        assert!(config.supervisor.is_none());
1616    }
1617
1618    #[test]
1619    fn parses_full_supervisor_section() {
1620        let tmp = TempDir::new().unwrap();
1621        let path = tmp.path().join("config.toml");
1622        write_file(
1623            &path,
1624            "[supervisor]\n\
1625             enabled = true\n\
1626             cli = \"claude\"\n\
1627             test_command = \"just check\"\n\
1628             agent_approval = \"full-auto\"\n",
1629        );
1630
1631        let config = load_config_file(&path).unwrap().unwrap();
1632        let supervisor = config.supervisor.unwrap();
1633        assert!(supervisor.enabled);
1634        assert_eq!(supervisor.cli.as_deref(), Some("claude"));
1635        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
1636        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1637    }
1638
1639    #[test]
1640    fn parses_partial_supervisor_section() {
1641        let tmp = TempDir::new().unwrap();
1642        let path = tmp.path().join("config.toml");
1643        write_file(&path, "[supervisor]\nenabled = true\n");
1644
1645        let config = load_config_file(&path).unwrap().unwrap();
1646        let supervisor = config.supervisor.unwrap();
1647        assert!(supervisor.enabled);
1648        assert_eq!(supervisor.cli, None);
1649        assert_eq!(supervisor.test_command, None);
1650        assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
1651    }
1652
1653    #[test]
1654    fn rejects_invalid_approval_level() {
1655        let tmp = TempDir::new().unwrap();
1656        let path = tmp.path().join("config.toml");
1657        write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
1658
1659        let err = load_config_file(&path).unwrap_err();
1660        assert!(
1661            err.to_string().contains("yolo"),
1662            "error should mention invalid value, got: {err}"
1663        );
1664    }
1665
1666    #[test]
1667    fn supervisor_round_trips_through_save_and_load() {
1668        let tmp = TempDir::new().unwrap();
1669        let config_path = tmp.path().join("config.toml");
1670
1671        let original = PawConfig {
1672            supervisor: Some(SupervisorConfig {
1673                enabled: true,
1674                cli: Some("claude".into()),
1675                test_command: Some("just check".into()),
1676                lint_command: None,
1677                build_command: None,
1678                doc_build_command: None,
1679                spec_validate_command: None,
1680                fmt_check_command: None,
1681                security_audit_command: None,
1682                agent_approval: ApprovalLevel::FullAuto,
1683                auto_approve: None,
1684                conflict: ConflictConfig::default(),
1685                learnings: false,
1686                learnings_config: LearningsConfig::default(),
1687                common_dev_allowlist: CommonDevAllowlistConfig::default(),
1688            }),
1689            ..Default::default()
1690        };
1691
1692        save_config_to(&config_path, &original).unwrap();
1693        let loaded = load_config_file(&config_path).unwrap().unwrap();
1694        assert_eq!(loaded.supervisor, original.supervisor);
1695    }
1696
1697    // --- Gate-command fields (supervisor-gate-templating-v0-5-x) ---
1698
1699    #[test]
1700    fn gate_command_fields_default_to_none() {
1701        let tmp = TempDir::new().unwrap();
1702        let path = tmp.path().join("config.toml");
1703        write_file(&path, "[supervisor]\nenabled = true\n");
1704
1705        let config = load_config_file(&path).unwrap().unwrap();
1706        let supervisor = config.supervisor.unwrap();
1707        assert_eq!(supervisor.test_command, None);
1708        assert_eq!(supervisor.lint_command, None);
1709        assert_eq!(supervisor.build_command, None);
1710        assert_eq!(supervisor.doc_build_command, None);
1711        assert_eq!(supervisor.spec_validate_command, None);
1712        assert_eq!(supervisor.fmt_check_command, None);
1713        assert_eq!(supervisor.security_audit_command, None);
1714    }
1715
1716    #[test]
1717    fn gate_command_fields_round_trip() {
1718        let tmp = TempDir::new().unwrap();
1719        let config_path = tmp.path().join("config.toml");
1720
1721        let original = PawConfig {
1722            supervisor: Some(SupervisorConfig {
1723                enabled: true,
1724                cli: Some("claude".into()),
1725                test_command: Some("just check".into()),
1726                lint_command: Some("cargo clippy -- -D warnings".into()),
1727                build_command: Some("cargo build".into()),
1728                doc_build_command: Some("mdbook build docs/".into()),
1729                spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
1730                fmt_check_command: Some("cargo fmt --check".into()),
1731                security_audit_command: Some("cargo audit".into()),
1732                ..Default::default()
1733            }),
1734            ..Default::default()
1735        };
1736
1737        save_config_to(&config_path, &original).unwrap();
1738        let loaded = load_config_file(&config_path).unwrap().unwrap();
1739        assert_eq!(loaded.supervisor, original.supervisor);
1740    }
1741
1742    #[test]
1743    fn gate_command_fields_omit_from_toml_when_none() {
1744        let supervisor = SupervisorConfig {
1745            enabled: true,
1746            test_command: None,
1747            lint_command: None,
1748            build_command: None,
1749            doc_build_command: None,
1750            spec_validate_command: None,
1751            fmt_check_command: None,
1752            security_audit_command: None,
1753            ..Default::default()
1754        };
1755        let serialized = toml::to_string_pretty(&supervisor).unwrap();
1756        for key in [
1757            "test_command",
1758            "lint_command",
1759            "build_command",
1760            "doc_build_command",
1761            "spec_validate_command",
1762            "fmt_check_command",
1763            "security_audit_command",
1764        ] {
1765            assert!(
1766                !serialized.contains(key),
1767                "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
1768            );
1769        }
1770    }
1771
1772    // --- CommonDevAllowlistConfig ---
1773
1774    #[test]
1775    fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
1776        let tmp = TempDir::new().unwrap();
1777        let path = tmp.path().join("config.toml");
1778        write_file(&path, "[supervisor]\nenabled = true\n");
1779
1780        let config = load_config_file(&path).unwrap().unwrap();
1781        let supervisor = config.supervisor.unwrap();
1782        assert!(supervisor.common_dev_allowlist.enabled);
1783        assert!(supervisor.common_dev_allowlist.extra.is_empty());
1784    }
1785
1786    #[test]
1787    fn supervisor_common_dev_allowlist_disabled_opt_out() {
1788        let tmp = TempDir::new().unwrap();
1789        let path = tmp.path().join("config.toml");
1790        write_file(
1791            &path,
1792            "[supervisor]\nenabled = true\n\
1793             [supervisor.common_dev_allowlist]\nenabled = false\n",
1794        );
1795
1796        let config = load_config_file(&path).unwrap().unwrap();
1797        let supervisor = config.supervisor.unwrap();
1798        assert!(!supervisor.common_dev_allowlist.enabled);
1799        // extra still defaults to empty.
1800        assert!(supervisor.common_dev_allowlist.extra.is_empty());
1801    }
1802
1803    #[test]
1804    fn supervisor_common_dev_allowlist_extra_parsed() {
1805        let tmp = TempDir::new().unwrap();
1806        let path = tmp.path().join("config.toml");
1807        write_file(
1808            &path,
1809            "[supervisor]\nenabled = true\n\
1810             [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
1811        );
1812
1813        let config = load_config_file(&path).unwrap().unwrap();
1814        let supervisor = config.supervisor.unwrap();
1815        assert_eq!(
1816            supervisor.common_dev_allowlist.extra,
1817            vec!["pnpm test".to_string(), "deno fmt".to_string()],
1818        );
1819        // enabled stays at default true.
1820        assert!(supervisor.common_dev_allowlist.enabled);
1821    }
1822
1823    #[test]
1824    fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
1825        let tmp = TempDir::new().unwrap();
1826        let config_path = tmp.path().join("config.toml");
1827
1828        let original = PawConfig {
1829            supervisor: Some(SupervisorConfig {
1830                enabled: true,
1831                common_dev_allowlist: CommonDevAllowlistConfig {
1832                    enabled: false,
1833                    extra: vec!["pnpm test".into(), "uv pip install".into()],
1834                },
1835                ..Default::default()
1836            }),
1837            ..Default::default()
1838        };
1839
1840        save_config_to(&config_path, &original).unwrap();
1841        let loaded = load_config_file(&config_path).unwrap().unwrap();
1842        assert_eq!(loaded.supervisor, original.supervisor);
1843    }
1844
1845    #[test]
1846    fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
1847        // A pre-v0.5 supervisor config that omits the new sub-table must
1848        // still load and yield the documented defaults.
1849        let tmp = TempDir::new().unwrap();
1850        let path = tmp.path().join("config.toml");
1851        write_file(
1852            &path,
1853            "[supervisor]\n\
1854             enabled = true\n\
1855             cli = \"claude\"\n\
1856             test_command = \"just check\"\n\
1857             agent_approval = \"auto\"\n\
1858             [supervisor.conflict]\n\
1859             window_seconds = 60\n",
1860        );
1861
1862        let config = load_config_file(&path).unwrap().unwrap();
1863        let supervisor = config.supervisor.unwrap();
1864        assert!(supervisor.common_dev_allowlist.enabled);
1865        assert!(supervisor.common_dev_allowlist.extra.is_empty());
1866    }
1867
1868    #[test]
1869    fn generated_default_config_template_contains_common_dev_allowlist_section() {
1870        let template = generate_default_config();
1871        assert!(
1872            template.contains("[supervisor.common_dev_allowlist]"),
1873            "default template should document the new sub-table",
1874        );
1875        assert!(
1876            template.contains("enabled = true"),
1877            "template should show the enabled default",
1878        );
1879        assert!(
1880            template.contains("extra ="),
1881            "template should illustrate the extra field",
1882        );
1883    }
1884
1885    // --- LearningsConfig (learnings-mode) ---
1886
1887    #[test]
1888    fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
1889        // [supervisor] present without `learnings` → learnings = false
1890        let tmp = TempDir::new().unwrap();
1891        let path = tmp.path().join("config.toml");
1892        write_file(&path, "[supervisor]\nenabled = true\n");
1893
1894        let config = load_config_file(&path).unwrap().unwrap();
1895        let supervisor = config.supervisor.unwrap();
1896        assert!(!supervisor.learnings);
1897        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1898    }
1899
1900    #[test]
1901    fn learnings_true_loads() {
1902        let tmp = TempDir::new().unwrap();
1903        let path = tmp.path().join("config.toml");
1904        write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
1905
1906        let config = load_config_file(&path).unwrap().unwrap();
1907        let supervisor = config.supervisor.unwrap();
1908        assert!(supervisor.learnings);
1909        // Defaults still applied for the nested table.
1910        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1911    }
1912
1913    #[test]
1914    fn learnings_config_custom_flush_interval_is_honoured() {
1915        let tmp = TempDir::new().unwrap();
1916        let path = tmp.path().join("config.toml");
1917        write_file(
1918            &path,
1919            "[supervisor]\n\
1920             enabled = true\n\
1921             learnings = true\n\
1922             [supervisor.learnings_config]\n\
1923             flush_interval_seconds = 30\n",
1924        );
1925
1926        let config = load_config_file(&path).unwrap().unwrap();
1927        let supervisor = config.supervisor.unwrap();
1928        assert!(supervisor.learnings);
1929        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
1930    }
1931
1932    #[test]
1933    fn learnings_config_defaults_when_table_absent() {
1934        // [supervisor.learnings_config] omitted → flush_interval_seconds = 60
1935        let cfg = LearningsConfig::default();
1936        assert_eq!(cfg.flush_interval_seconds, 60);
1937    }
1938
1939    #[test]
1940    fn pre_v050_config_loads_with_learnings_false() {
1941        // A config produced before v0.5.0 (no `learnings` field, no
1942        // `[supervisor.learnings_config]` table) parses cleanly and yields
1943        // `learnings = false`.
1944        let tmp = TempDir::new().unwrap();
1945        let path = tmp.path().join("config.toml");
1946        write_file(
1947            &path,
1948            "default_cli = \"claude\"\n\
1949             [supervisor]\n\
1950             enabled = true\n\
1951             agent_approval = \"auto\"\n",
1952        );
1953
1954        let config = load_config_file(&path).unwrap().unwrap();
1955        let supervisor = config.supervisor.unwrap();
1956        assert!(!supervisor.learnings);
1957        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1958    }
1959
1960    #[test]
1961    fn learnings_round_trips_through_save_and_load() {
1962        let tmp = TempDir::new().unwrap();
1963        let config_path = tmp.path().join("config.toml");
1964
1965        let original = PawConfig {
1966            supervisor: Some(SupervisorConfig {
1967                enabled: true,
1968                learnings: true,
1969                learnings_config: LearningsConfig {
1970                    flush_interval_seconds: 90,
1971                },
1972                ..Default::default()
1973            }),
1974            ..Default::default()
1975        };
1976
1977        save_config_to(&config_path, &original).unwrap();
1978        let loaded = load_config_file(&config_path).unwrap().unwrap();
1979        assert_eq!(loaded.supervisor, original.supervisor);
1980        let supervisor = loaded.supervisor.unwrap();
1981        assert!(supervisor.learnings);
1982        assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
1983    }
1984
1985    #[test]
1986    fn existing_v030_config_loads_without_supervisor() {
1987        let tmp = TempDir::new().unwrap();
1988        let path = tmp.path().join("config.toml");
1989        write_file(
1990            &path,
1991            "default_cli = \"claude\"\n\
1992             mouse = true\n\
1993             [broker]\n\
1994             enabled = true\n\
1995             [logging]\n\
1996             enabled = false\n",
1997        );
1998
1999        let config = load_config_file(&path).unwrap().unwrap();
2000        assert_eq!(config.default_cli.as_deref(), Some("claude"));
2001        assert!(config.broker.enabled);
2002        assert!(config.supervisor.is_none());
2003    }
2004
2005    #[test]
2006    fn generated_default_config_contains_commented_supervisor_section() {
2007        let output = generate_default_config();
2008        assert!(output.contains("[supervisor]"));
2009        assert!(output.contains("enabled"));
2010        assert!(output.contains("test_command"));
2011        assert!(output.contains("agent_approval"));
2012    }
2013
2014    // --- DashboardConfig ---
2015
2016    #[test]
2017    fn dashboard_config_defaults_to_disabled() {
2018        let config = DashboardConfig::default();
2019        assert!(!config.show_message_log);
2020    }
2021
2022    #[test]
2023    fn parses_dashboard_section_with_show_message_log() {
2024        let tmp = TempDir::new().unwrap();
2025        let path = tmp.path().join("config.toml");
2026        write_file(&path, "[dashboard]\nshow_message_log = true\n");
2027
2028        let config = load_config_file(&path).unwrap().unwrap();
2029        let dashboard = config.dashboard.unwrap();
2030        assert!(dashboard.show_message_log);
2031    }
2032
2033    #[test]
2034    fn dashboard_is_none_when_section_absent() {
2035        let tmp = TempDir::new().unwrap();
2036        let path = tmp.path().join("config.toml");
2037        write_file(&path, "default_cli = \"claude\"\n");
2038
2039        let config = load_config_file(&path).unwrap().unwrap();
2040        assert!(config.dashboard.is_none());
2041    }
2042
2043    #[test]
2044    fn dashboard_merge_repo_wins() {
2045        let tmp = TempDir::new().unwrap();
2046        let global_path = tmp.path().join("global").join("config.toml");
2047        let repo_root = tmp.path().join("repo");
2048        fs::create_dir_all(&repo_root).unwrap();
2049
2050        write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2051        write_file(
2052            &repo_config_path(&repo_root),
2053            "[dashboard]\nshow_message_log = true\n",
2054        );
2055
2056        let config = load_config_from(&global_path, &repo_root).unwrap();
2057        let dashboard = config.dashboard.unwrap();
2058        assert!(dashboard.show_message_log);
2059    }
2060
2061    #[test]
2062    fn dashboard_round_trip_through_save_and_load() {
2063        let tmp = TempDir::new().unwrap();
2064        let config_path = tmp.path().join("config.toml");
2065
2066        let original = PawConfig {
2067            dashboard: Some(DashboardConfig {
2068                show_message_log: true,
2069            }),
2070            ..Default::default()
2071        };
2072
2073        save_config_to(&config_path, &original).unwrap();
2074        let loaded = load_config_file(&config_path).unwrap().unwrap();
2075        assert_eq!(loaded.dashboard, original.dashboard);
2076        assert!(loaded.dashboard.unwrap().show_message_log);
2077    }
2078
2079    #[test]
2080    fn get_dashboard_returns_none_when_not_configured() {
2081        let config = PawConfig::default();
2082        assert!(config.get_dashboard().is_none());
2083    }
2084
2085    #[test]
2086    fn get_dashboard_returns_config_when_present() {
2087        let config = PawConfig {
2088            dashboard: Some(DashboardConfig {
2089                show_message_log: true,
2090            }),
2091            ..Default::default()
2092        };
2093        let dashboard = config.get_dashboard().unwrap();
2094        assert!(dashboard.show_message_log);
2095    }
2096
2097    // --- approval_flags mapping ---
2098
2099    #[test]
2100    fn approval_flags_claude_full_auto() {
2101        assert_eq!(
2102            approval_flags("claude", &ApprovalLevel::FullAuto),
2103            "--dangerously-skip-permissions"
2104        );
2105    }
2106
2107    #[test]
2108    fn approval_flags_codex_auto() {
2109        assert_eq!(
2110            approval_flags("codex", &ApprovalLevel::Auto),
2111            "--approval-mode=auto-edit"
2112        );
2113    }
2114
2115    #[test]
2116    fn approval_flags_codex_full_auto() {
2117        assert_eq!(
2118            approval_flags("codex", &ApprovalLevel::FullAuto),
2119            "--approval-mode=full-auto"
2120        );
2121    }
2122
2123    #[test]
2124    fn approval_flags_unknown_cli_is_empty() {
2125        assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
2126    }
2127
2128    #[test]
2129    fn approval_flags_manual_is_empty() {
2130        assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
2131        assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
2132    }
2133
2134    #[test]
2135    fn approval_flags_is_deterministic() {
2136        let first = approval_flags("claude", &ApprovalLevel::FullAuto);
2137        let second = approval_flags("claude", &ApprovalLevel::FullAuto);
2138        assert_eq!(first, second);
2139    }
2140
2141    #[test]
2142    fn supervisor_merge_repo_wins() {
2143        let tmp = TempDir::new().unwrap();
2144        let global_path = tmp.path().join("global").join("config.toml");
2145        let repo_root = tmp.path().join("repo");
2146        fs::create_dir_all(&repo_root).unwrap();
2147
2148        write_file(
2149            &global_path,
2150            "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
2151        );
2152        write_file(
2153            &repo_config_path(&repo_root),
2154            "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
2155        );
2156
2157        let config = load_config_from(&global_path, &repo_root).unwrap();
2158        let supervisor = config.supervisor.unwrap();
2159        assert!(supervisor.enabled);
2160        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2161    }
2162
2163    #[test]
2164    fn broker_config_round_trip() {
2165        let tmp = TempDir::new().unwrap();
2166        let config_path = tmp.path().join("config.toml");
2167
2168        let original = PawConfig {
2169            broker: BrokerConfig {
2170                enabled: true,
2171                port: 9200,
2172                bind: "127.0.0.1".to_string(),
2173            },
2174            ..Default::default()
2175        };
2176
2177        save_config_to(&config_path, &original).unwrap();
2178        let loaded = load_config_file(&config_path).unwrap().unwrap();
2179        assert_eq!(loaded.broker.enabled, original.broker.enabled);
2180        assert_eq!(loaded.broker.port, original.broker.port);
2181        assert_eq!(loaded.broker.bind, original.broker.bind);
2182    }
2183
2184    // --- AutoApproveConfig (auto-approve-patterns / approval-configuration) ---
2185
2186    #[test]
2187    fn auto_approve_defaults_match_spec() {
2188        let cfg = AutoApproveConfig::default();
2189        assert!(cfg.enabled, "enabled defaults to true");
2190        assert!(
2191            cfg.safe_commands.is_empty(),
2192            "safe_commands defaults to empty"
2193        );
2194        assert_eq!(cfg.stall_threshold_seconds, 30);
2195        assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
2196    }
2197
2198    #[test]
2199    fn auto_approve_section_absent_keeps_supervisor_simple() {
2200        let tmp = TempDir::new().unwrap();
2201        let path = tmp.path().join("config.toml");
2202        write_file(&path, "[supervisor]\nenabled = true\n");
2203        let config = load_config_file(&path).unwrap().unwrap();
2204        let supervisor = config.supervisor.unwrap();
2205        assert!(supervisor.auto_approve.is_none());
2206    }
2207
2208    #[test]
2209    fn auto_approve_section_parses_full_body() {
2210        let tmp = TempDir::new().unwrap();
2211        let path = tmp.path().join("config.toml");
2212        write_file(
2213            &path,
2214            "[supervisor]\n\
2215             enabled = true\n\
2216             [supervisor.auto_approve]\n\
2217             enabled = false\n\
2218             safe_commands = [\"just smoke\"]\n\
2219             stall_threshold_seconds = 60\n\
2220             approval_level = \"conservative\"\n",
2221        );
2222        let config = load_config_file(&path).unwrap().unwrap();
2223        let aa = config.supervisor.unwrap().auto_approve.unwrap();
2224        assert!(!aa.enabled);
2225        assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
2226        assert_eq!(aa.stall_threshold_seconds, 60);
2227        assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
2228    }
2229
2230    #[test]
2231    fn auto_approve_enabled_defaults_to_true_when_omitted() {
2232        let tmp = TempDir::new().unwrap();
2233        let path = tmp.path().join("config.toml");
2234        write_file(
2235            &path,
2236            "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
2237        );
2238        let config = load_config_file(&path).unwrap().unwrap();
2239        let aa = config.supervisor.unwrap().auto_approve.unwrap();
2240        assert!(aa.enabled, "enabled should default to true");
2241    }
2242
2243    #[test]
2244    fn auto_approve_off_preset_forces_disabled() {
2245        let cfg = AutoApproveConfig {
2246            enabled: true,
2247            approval_level: ApprovalLevelPreset::Off,
2248            ..AutoApproveConfig::default()
2249        };
2250        let resolved = cfg.resolved();
2251        assert!(!resolved.enabled, "Off preset must force enabled = false");
2252    }
2253
2254    #[test]
2255    fn auto_approve_threshold_floor_clamps() {
2256        let cfg = AutoApproveConfig {
2257            stall_threshold_seconds: 0,
2258            ..AutoApproveConfig::default()
2259        };
2260        let resolved = cfg.resolved();
2261        assert_eq!(
2262            resolved.stall_threshold_seconds,
2263            AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
2264        );
2265    }
2266
2267    #[test]
2268    fn auto_approve_safe_preset_keeps_defaults() {
2269        let cfg = AutoApproveConfig {
2270            approval_level: ApprovalLevelPreset::Safe,
2271            ..AutoApproveConfig::default()
2272        };
2273        let wl = cfg.effective_whitelist();
2274        assert!(wl.iter().any(|c| c == "cargo test"));
2275        assert!(wl.iter().any(|c| c == "git push"));
2276        assert!(wl.iter().any(|c| c.starts_with("curl")));
2277    }
2278
2279    #[test]
2280    fn auto_approve_conservative_drops_push_and_curl() {
2281        let cfg = AutoApproveConfig {
2282            approval_level: ApprovalLevelPreset::Conservative,
2283            ..AutoApproveConfig::default()
2284        };
2285        let wl = cfg.effective_whitelist();
2286        assert!(wl.iter().any(|c| c == "cargo test"));
2287        assert!(
2288            !wl.iter().any(|c| c.starts_with("git push")),
2289            "conservative drops git push"
2290        );
2291        assert!(
2292            !wl.iter().any(|c| c.starts_with("curl")),
2293            "conservative drops curl"
2294        );
2295    }
2296
2297    #[test]
2298    fn auto_approve_extras_are_unioned_with_defaults() {
2299        let cfg = AutoApproveConfig {
2300            safe_commands: vec!["just lint".to_string(), "just test".to_string()],
2301            ..AutoApproveConfig::default()
2302        };
2303        let wl = cfg.effective_whitelist();
2304        assert!(wl.iter().any(|c| c == "cargo fmt"));
2305        assert!(wl.iter().any(|c| c == "just lint"));
2306        assert!(wl.iter().any(|c| c == "just test"));
2307    }
2308
2309    #[test]
2310    fn auto_approve_empty_extras_keep_defaults() {
2311        let cfg = AutoApproveConfig::default();
2312        let wl = cfg.effective_whitelist();
2313        assert!(wl.iter().any(|c| c == "cargo test"));
2314    }
2315
2316    /// Spec scenario `auto-approve-patterns/safe-command-classification`:
2317    /// "Config adds project-specific patterns" — a TOML config with
2318    /// `safe_commands = ["just smoke"]` must yield an effective whitelist
2319    /// such that `is_safe_command("just smoke -v", &whitelist)` is true.
2320    /// "Config does not weaken defaults" — `safe_commands = []` must keep
2321    /// the built-in defaults available to `is_safe_command`.
2322    #[test]
2323    fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
2324        use crate::supervisor::auto_approve::is_safe_command;
2325
2326        // (1) Extras case: a project-specific entry parsed from TOML must
2327        //     classify a command using that prefix as safe.
2328        let tmp = TempDir::new().unwrap();
2329        let extras_path = tmp.path().join("extras.toml");
2330        write_file(
2331            &extras_path,
2332            "[supervisor]\n\
2333             enabled = true\n\
2334             [supervisor.auto_approve]\n\
2335             safe_commands = [\"just smoke\"]\n",
2336        );
2337        let extras_config = load_config_file(&extras_path).unwrap().unwrap();
2338        let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
2339        let extras_whitelist = extras_aa.effective_whitelist();
2340        assert!(
2341            is_safe_command("just smoke -v", &extras_whitelist),
2342            "TOML extra `just smoke` must accept `just smoke -v`"
2343        );
2344        // The defaults must still be present alongside the extra.
2345        assert!(
2346            is_safe_command("cargo test", &extras_whitelist),
2347            "extras must not displace built-in defaults"
2348        );
2349
2350        // (2) Empty extras: the effective whitelist must still classify the
2351        //     built-in defaults (e.g. `cargo test`) as safe.
2352        let empty_path = tmp.path().join("empty.toml");
2353        write_file(
2354            &empty_path,
2355            "[supervisor]\n\
2356             enabled = true\n\
2357             [supervisor.auto_approve]\n\
2358             safe_commands = []\n",
2359        );
2360        let empty_config = load_config_file(&empty_path).unwrap().unwrap();
2361        let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
2362        let empty_whitelist = empty_aa.effective_whitelist();
2363        assert!(
2364            is_safe_command("cargo test", &empty_whitelist),
2365            "empty safe_commands must keep built-in defaults"
2366        );
2367        assert!(
2368            is_safe_command("cargo fmt --check", &empty_whitelist),
2369            "empty safe_commands must keep `cargo fmt` default"
2370        );
2371        // A command outside the defaults must still be rejected.
2372        assert!(
2373            !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
2374            "empty safe_commands must not whitelist arbitrary commands"
2375        );
2376    }
2377
2378    // --- ConflictConfig (supervisor.conflict sub-table) ---
2379
2380    #[test]
2381    fn conflict_config_defaults_match_spec() {
2382        let cfg = ConflictConfig::default();
2383        assert_eq!(cfg.window_seconds, 120);
2384        assert!(cfg.warn_on_intent_overlap);
2385        assert!(cfg.escalate_on_violation);
2386    }
2387
2388    #[test]
2389    fn supervisor_with_no_conflict_section_loads_defaults() {
2390        let tmp = TempDir::new().unwrap();
2391        let path = tmp.path().join("config.toml");
2392        write_file(&path, "[supervisor]\nenabled = true\n");
2393        let supervisor = load_config_file(&path)
2394            .unwrap()
2395            .unwrap()
2396            .supervisor
2397            .unwrap();
2398        assert_eq!(supervisor.conflict.window_seconds, 120);
2399        assert!(supervisor.conflict.warn_on_intent_overlap);
2400        assert!(supervisor.conflict.escalate_on_violation);
2401    }
2402
2403    #[test]
2404    fn conflict_section_with_all_fields_overrides_defaults() {
2405        let tmp = TempDir::new().unwrap();
2406        let path = tmp.path().join("config.toml");
2407        write_file(
2408            &path,
2409            "[supervisor]\n\
2410             enabled = true\n\
2411             [supervisor.conflict]\n\
2412             window_seconds = 300\n\
2413             warn_on_intent_overlap = false\n\
2414             escalate_on_violation = false\n",
2415        );
2416        let conflict = load_config_file(&path)
2417            .unwrap()
2418            .unwrap()
2419            .supervisor
2420            .unwrap()
2421            .conflict;
2422        assert_eq!(conflict.window_seconds, 300);
2423        assert!(!conflict.warn_on_intent_overlap);
2424        assert!(!conflict.escalate_on_violation);
2425    }
2426
2427    #[test]
2428    fn conflict_section_with_partial_fields_keeps_other_defaults() {
2429        let tmp = TempDir::new().unwrap();
2430        let path = tmp.path().join("config.toml");
2431        write_file(
2432            &path,
2433            "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
2434        );
2435        let conflict = load_config_file(&path)
2436            .unwrap()
2437            .unwrap()
2438            .supervisor
2439            .unwrap()
2440            .conflict;
2441        assert_eq!(conflict.window_seconds, 60);
2442        assert!(conflict.warn_on_intent_overlap);
2443        assert!(conflict.escalate_on_violation);
2444    }
2445
2446    #[test]
2447    fn pre_v05_config_without_conflict_section_loads() {
2448        let tmp = TempDir::new().unwrap();
2449        let path = tmp.path().join("config.toml");
2450        // A v0.4-style config: supervisor enabled but no [supervisor.conflict].
2451        write_file(
2452            &path,
2453            "default_cli = \"claude\"\n\
2454             [supervisor]\n\
2455             enabled = true\n\
2456             agent_approval = \"auto\"\n",
2457        );
2458        let config = load_config_file(&path).unwrap().unwrap();
2459        let supervisor = config.supervisor.unwrap();
2460        assert!(supervisor.enabled);
2461        // The conflict sub-table defaults to ConflictConfig::default().
2462        assert_eq!(supervisor.conflict, ConflictConfig::default());
2463    }
2464
2465    #[test]
2466    fn conflict_config_round_trips_through_save_and_load() {
2467        let tmp = TempDir::new().unwrap();
2468        let config_path = tmp.path().join("config.toml");
2469        let original = PawConfig {
2470            supervisor: Some(SupervisorConfig {
2471                enabled: true,
2472                conflict: ConflictConfig {
2473                    window_seconds: 90,
2474                    warn_on_intent_overlap: false,
2475                    escalate_on_violation: true,
2476                },
2477                ..Default::default()
2478            }),
2479            ..Default::default()
2480        };
2481        save_config_to(&config_path, &original).unwrap();
2482        let loaded = load_config_file(&config_path).unwrap().unwrap();
2483        assert_eq!(loaded.supervisor, original.supervisor);
2484    }
2485
2486    #[test]
2487    fn v030_config_loads_without_auto_approve() {
2488        // Backward-compat: an existing v0.3.0 config that has neither
2489        // [supervisor] nor [supervisor.auto_approve] must parse cleanly.
2490        let tmp = TempDir::new().unwrap();
2491        let path = tmp.path().join("config.toml");
2492        write_file(
2493            &path,
2494            "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
2495        );
2496        let config = load_config_file(&path).unwrap().unwrap();
2497        assert!(config.supervisor.is_none());
2498        assert!(config.broker.enabled);
2499    }
2500
2501    // --- GovernanceConfig (governance-config v0.5.0) ---
2502
2503    /// Helper: lays out a repo with `.git-paw/config.toml` and an optional
2504    /// `SpecKit` `memory/constitution.md` so the `load_config_from`
2505    /// auto-wiring path can be exercised end-to-end.
2506    fn write_repo_config(repo_root: &Path, toml: &str) {
2507        write_file(&repo_config_path(repo_root), toml);
2508    }
2509
2510    fn missing_global(tmp: &TempDir) -> PathBuf {
2511        tmp.path().join("nonexistent-global").join("config.toml")
2512    }
2513
2514    // 3.1 No [governance] section → all paths None.
2515    #[test]
2516    fn governance_defaults_to_all_none_when_section_absent() {
2517        let tmp = TempDir::new().unwrap();
2518        let path = tmp.path().join("config.toml");
2519        write_file(&path, "default_cli = \"claude\"\n");
2520
2521        let config = load_config_file(&path).unwrap().unwrap();
2522        assert!(config.governance.adr.is_none());
2523        assert!(config.governance.test_strategy.is_none());
2524        assert!(config.governance.security.is_none());
2525        assert!(config.governance.dod.is_none());
2526        assert!(config.governance.constitution.is_none());
2527    }
2528
2529    // 3.2 All paths populated.
2530    #[test]
2531    fn governance_all_paths_populated() {
2532        let tmp = TempDir::new().unwrap();
2533        let path = tmp.path().join("config.toml");
2534        write_file(
2535            &path,
2536            "[governance]\n\
2537             adr = \"docs/adr\"\n\
2538             test_strategy = \"docs/test-strategy.md\"\n\
2539             security = \"docs/security-checklist.md\"\n\
2540             dod = \"docs/definition-of-done.md\"\n\
2541             constitution = \".specify/memory/constitution.md\"\n",
2542        );
2543
2544        let config = load_config_file(&path).unwrap().unwrap();
2545        assert_eq!(
2546            config.governance.adr.as_deref(),
2547            Some(Path::new("docs/adr"))
2548        );
2549        assert_eq!(
2550            config.governance.test_strategy.as_deref(),
2551            Some(Path::new("docs/test-strategy.md"))
2552        );
2553        assert_eq!(
2554            config.governance.security.as_deref(),
2555            Some(Path::new("docs/security-checklist.md"))
2556        );
2557        assert_eq!(
2558            config.governance.dod.as_deref(),
2559            Some(Path::new("docs/definition-of-done.md"))
2560        );
2561        assert_eq!(
2562            config.governance.constitution.as_deref(),
2563            Some(Path::new(".specify/memory/constitution.md"))
2564        );
2565    }
2566
2567    // 3.3 Partial paths.
2568    #[test]
2569    fn governance_partial_paths_only_some_fields_populated() {
2570        let tmp = TempDir::new().unwrap();
2571        let path = tmp.path().join("config.toml");
2572        write_file(
2573            &path,
2574            "[governance]\n\
2575             dod = \"docs/dod.md\"\n\
2576             security = \"docs/security.md\"\n",
2577        );
2578
2579        let config = load_config_file(&path).unwrap().unwrap();
2580        assert_eq!(
2581            config.governance.dod.as_deref(),
2582            Some(Path::new("docs/dod.md"))
2583        );
2584        assert_eq!(
2585            config.governance.security.as_deref(),
2586            Some(Path::new("docs/security.md"))
2587        );
2588        assert!(config.governance.adr.is_none());
2589        assert!(config.governance.test_strategy.is_none());
2590        assert!(config.governance.constitution.is_none());
2591    }
2592
2593    // 3.4 Absolute path preserved as-is.
2594    #[test]
2595    fn governance_absolute_path_preserved_as_is() {
2596        let tmp = TempDir::new().unwrap();
2597        let path = tmp.path().join("config.toml");
2598        write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
2599
2600        let config = load_config_file(&path).unwrap().unwrap();
2601        assert_eq!(
2602            config.governance.adr,
2603            Some(PathBuf::from("/absolute/path/to/adr"))
2604        );
2605    }
2606
2607    // 3.5 Non-existent path loads cleanly without error.
2608    #[test]
2609    fn governance_nonexistent_path_loads_cleanly() {
2610        let tmp = TempDir::new().unwrap();
2611        let path = tmp.path().join("config.toml");
2612        write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
2613
2614        let config = load_config_file(&path).unwrap().unwrap();
2615        assert_eq!(
2616            config.governance.dod,
2617            Some(PathBuf::from("docs/never-existed.md"))
2618        );
2619    }
2620
2621    // 3.6 Round-trip via save → load.
2622    #[test]
2623    fn governance_round_trips_through_save_and_load() {
2624        let tmp = TempDir::new().unwrap();
2625        let config_path = tmp.path().join("config.toml");
2626
2627        let original = PawConfig {
2628            governance: GovernanceConfig {
2629                adr: Some(PathBuf::from("docs/adr")),
2630                test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
2631                security: Some(PathBuf::from("docs/security.md")),
2632                dod: Some(PathBuf::from("docs/dod.md")),
2633                constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
2634            },
2635            ..Default::default()
2636        };
2637
2638        save_config_to(&config_path, &original).unwrap();
2639        let loaded = load_config_file(&config_path).unwrap().unwrap();
2640        assert_eq!(loaded.governance, original.governance);
2641    }
2642
2643    // 3.7 v0.4 fixture (no [governance]) loads with defaults.
2644    #[test]
2645    fn governance_v04_config_without_section_loads_with_defaults() {
2646        let tmp = TempDir::new().unwrap();
2647        let path = tmp.path().join("config.toml");
2648        write_file(
2649            &path,
2650            "default_cli = \"claude\"\n\
2651             mouse = true\n\
2652             [broker]\n\
2653             enabled = true\n\
2654             [supervisor]\n\
2655             enabled = true\n\
2656             [specs]\n\
2657             dir = \"specs\"\n\
2658             type = \"openspec\"\n\
2659             [clis.foo]\n\
2660             command = \"/bin/foo\"\n",
2661        );
2662
2663        let config = load_config_file(&path).unwrap().unwrap();
2664        assert_eq!(config.governance, GovernanceConfig::default());
2665        assert!(config.governance.adr.is_none());
2666        assert!(config.governance.test_strategy.is_none());
2667        assert!(config.governance.security.is_none());
2668        assert!(config.governance.dod.is_none());
2669        assert!(config.governance.constitution.is_none());
2670    }
2671
2672    // 3.8 GovernanceConfig::default() exposes only the five path fields
2673    // (no `gates` field) — compile-time-style assertion via destructuring.
2674    #[test]
2675    fn governance_default_has_only_five_path_fields() {
2676        // If a future change adds a `gates` (or any other) field, this
2677        // destructure stops compiling, forcing the change author to
2678        // revisit the capability boundary explicitly.
2679        let GovernanceConfig {
2680            adr,
2681            test_strategy,
2682            security,
2683            dod,
2684            constitution,
2685        } = GovernanceConfig::default();
2686        assert!(adr.is_none());
2687        assert!(test_strategy.is_none());
2688        assert!(security.is_none());
2689        assert!(dod.is_none());
2690        assert!(constitution.is_none());
2691    }
2692
2693    // 4.1 Auto-wires constitution when SpecKit detected + field unset.
2694    #[test]
2695    fn governance_auto_wires_constitution_when_speckit_detected() {
2696        let tmp = TempDir::new().unwrap();
2697        let repo_root = tmp.path().join("repo");
2698        let specify = repo_root.join(".specify");
2699        let specs = specify.join("specs");
2700        let memory = specify.join("memory");
2701        fs::create_dir_all(&specs).unwrap();
2702        fs::create_dir_all(&memory).unwrap();
2703        let constitution = memory.join("constitution.md");
2704        fs::write(&constitution, "# Constitution\n").unwrap();
2705
2706        write_repo_config(
2707            &repo_root,
2708            "[specs]\n\
2709             type = \"speckit\"\n\
2710             dir = \".specify/specs\"\n",
2711        );
2712
2713        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2714        assert_eq!(
2715            config.governance.constitution.as_deref(),
2716            Some(constitution.as_path())
2717        );
2718    }
2719
2720    // 4.2 Explicit governance.constitution preserved unchanged.
2721    #[test]
2722    fn governance_explicit_constitution_preserved_over_auto_wiring() {
2723        let tmp = TempDir::new().unwrap();
2724        let repo_root = tmp.path().join("repo");
2725        let specify = repo_root.join(".specify");
2726        let specs = specify.join("specs");
2727        let memory = specify.join("memory");
2728        fs::create_dir_all(&specs).unwrap();
2729        fs::create_dir_all(&memory).unwrap();
2730        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2731
2732        write_repo_config(
2733            &repo_root,
2734            "[specs]\n\
2735             type = \"speckit\"\n\
2736             dir = \".specify/specs\"\n\
2737             [governance]\n\
2738             constitution = \"docs/principles.md\"\n",
2739        );
2740
2741        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2742        assert_eq!(
2743            config.governance.constitution,
2744            Some(PathBuf::from("docs/principles.md"))
2745        );
2746    }
2747
2748    // 4.3 Auto-wiring skipped for non-speckit backends.
2749    #[test]
2750    fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
2751        let tmp = TempDir::new().unwrap();
2752        let repo_root = tmp.path().join("repo");
2753        let specify = repo_root.join(".specify");
2754        let memory = specify.join("memory");
2755        fs::create_dir_all(&memory).unwrap();
2756        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2757        fs::create_dir_all(repo_root.join("specs")).unwrap();
2758
2759        write_repo_config(
2760            &repo_root,
2761            "[specs]\n\
2762             type = \"openspec\"\n\
2763             dir = \"specs\"\n",
2764        );
2765
2766        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2767        assert!(config.governance.constitution.is_none());
2768    }
2769
2770    // 4.4 Auto-wiring skipped when [specs] is absent entirely.
2771    #[test]
2772    fn governance_auto_wiring_skipped_when_specs_section_absent() {
2773        let tmp = TempDir::new().unwrap();
2774        let repo_root = tmp.path().join("repo");
2775        let memory = repo_root.join(".specify").join("memory");
2776        fs::create_dir_all(&memory).unwrap();
2777        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2778        fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
2779
2780        write_repo_config(&repo_root, "default_cli = \"claude\"\n");
2781
2782        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2783        assert!(config.governance.constitution.is_none());
2784    }
2785
2786    // 4.5 SpecKit active but constitution.md absent → stays None, no error.
2787    #[test]
2788    fn governance_auto_wiring_skipped_when_constitution_md_absent() {
2789        let tmp = TempDir::new().unwrap();
2790        let repo_root = tmp.path().join("repo");
2791        let specs = repo_root.join(".specify").join("specs");
2792        fs::create_dir_all(&specs).unwrap();
2793        // No memory/constitution.md.
2794
2795        write_repo_config(
2796            &repo_root,
2797            "[specs]\n\
2798             type = \"speckit\"\n\
2799             dir = \".specify/specs\"\n",
2800        );
2801
2802        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2803        assert!(config.governance.constitution.is_none());
2804    }
2805
2806    // 4.6 Explicit empty-string constitution preserved as Some("").
2807    #[test]
2808    fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
2809        let tmp = TempDir::new().unwrap();
2810        let repo_root = tmp.path().join("repo");
2811        let specify = repo_root.join(".specify");
2812        let specs = specify.join("specs");
2813        let memory = specify.join("memory");
2814        fs::create_dir_all(&specs).unwrap();
2815        fs::create_dir_all(&memory).unwrap();
2816        fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2817
2818        write_repo_config(
2819            &repo_root,
2820            "[specs]\n\
2821             type = \"speckit\"\n\
2822             dir = \".specify/specs\"\n\
2823             [governance]\n\
2824             constitution = \"\"\n",
2825        );
2826
2827        let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2828        assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
2829    }
2830
2831    // Merge: global and repo each contribute independent paths.
2832    #[test]
2833    fn governance_merge_fields_independently_across_global_and_repo() {
2834        let tmp = TempDir::new().unwrap();
2835        let global_path = tmp.path().join("global").join("config.toml");
2836        let repo_root = tmp.path().join("repo");
2837        fs::create_dir_all(&repo_root).unwrap();
2838
2839        write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
2840        write_file(
2841            &repo_config_path(&repo_root),
2842            "[governance]\ndod = \"docs/dod.md\"\n",
2843        );
2844
2845        let config = load_config_from(&global_path, &repo_root).unwrap();
2846        assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
2847        assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
2848    }
2849
2850    // Merge precedence: repo wins per-field when both set.
2851    #[test]
2852    fn governance_merge_repo_wins_per_field_when_both_set() {
2853        let tmp = TempDir::new().unwrap();
2854        let global_path = tmp.path().join("global").join("config.toml");
2855        let repo_root = tmp.path().join("repo");
2856        fs::create_dir_all(&repo_root).unwrap();
2857
2858        write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
2859        write_file(
2860            &repo_config_path(&repo_root),
2861            "[governance]\nadr = \"docs/repo-adr\"\n",
2862        );
2863
2864        let config = load_config_from(&global_path, &repo_root).unwrap();
2865        assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
2866    }
2867
2868    // load_repo_config also applies auto-wiring.
2869    #[test]
2870    fn governance_load_repo_config_also_auto_wires_constitution() {
2871        let tmp = TempDir::new().unwrap();
2872        let repo_root = tmp.path().join("repo");
2873        let specify = repo_root.join(".specify");
2874        let specs = specify.join("specs");
2875        let memory = specify.join("memory");
2876        fs::create_dir_all(&specs).unwrap();
2877        fs::create_dir_all(&memory).unwrap();
2878        let constitution = memory.join("constitution.md");
2879        fs::write(&constitution, "# Constitution\n").unwrap();
2880
2881        write_repo_config(
2882            &repo_root,
2883            "[specs]\n\
2884             type = \"speckit\"\n\
2885             dir = \".specify/specs\"\n",
2886        );
2887
2888        let config = load_repo_config(&repo_root).unwrap();
2889        assert_eq!(
2890            config.governance.constitution.as_deref(),
2891            Some(constitution.as_path())
2892        );
2893    }
2894
2895    // --- load_config user_config_path override (config-test-isolation) ---
2896
2897    #[test]
2898    fn load_config_with_some_pins_global_to_override_path() {
2899        let tmp = TempDir::new().unwrap();
2900        let repo_root = tmp.path().join("repo");
2901        fs::create_dir_all(&repo_root).unwrap();
2902
2903        let global_a = tmp.path().join("global-A.toml");
2904        let global_b = tmp.path().join("global-B.toml");
2905        write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
2906        write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
2907
2908        let config = load_config(&repo_root, Some(&global_a)).unwrap();
2909        assert!(config.clis.contains_key("cli-A"));
2910        assert!(!config.clis.contains_key("cli-B"));
2911    }
2912
2913    #[test]
2914    fn load_config_with_some_nonexistent_returns_defaults() {
2915        let tmp = TempDir::new().unwrap();
2916        let repo_root = tmp.path().join("repo");
2917        fs::create_dir_all(&repo_root).unwrap();
2918        let missing = tmp.path().join("does-not-exist.toml");
2919
2920        let config = load_config(&repo_root, Some(&missing)).unwrap();
2921        assert_eq!(config, PawConfig::default());
2922    }
2923
2924    // Note: a `load_config_with_none_reads_platform_default_global` test is
2925    // intentionally omitted. Asserting that `None` resolves to
2926    // `global_config_path()` would require either writing to the dev
2927    // machine's real `~/Library/Application Support/git-paw/config.toml`
2928    // (polluting it) or `serial_test` + env-var manipulation of `HOME` /
2929    // `XDG_CONFIG_HOME` (brittle, slows the suite). The `None` branch is
2930    // covered behaviourally by the 8 production call sites in `src/main.rs`
2931    // and the v0.4 test suite that continues to pass.
2932
2933    #[test]
2934    fn load_config_override_does_not_affect_repo_resolution() {
2935        let tmp = TempDir::new().unwrap();
2936        let repo_root = tmp.path().join("repo");
2937        fs::create_dir_all(&repo_root).unwrap();
2938        write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
2939
2940        let global_path = tmp.path().join("global.toml");
2941        write_file(&global_path, "default_cli = \"gemini\"\n");
2942
2943        let config = load_config(&repo_root, Some(&global_path)).unwrap();
2944        assert_eq!(config.default_cli.as_deref(), Some("claude"));
2945    }
2946
2947    // Maps to scenario "GovernanceConfig has no gates field" from
2948    // governance-config. The struct does not enable `deny_unknown_fields`, so
2949    // unknown sections deserialise silently; this test asserts the round-trip
2950    // representation omits any `[governance.gates]` section and the loaded
2951    // governance config keeps only the documented document-pointer fields.
2952    // (test-coverage-v0-5-0 task 9.1)
2953    #[test]
2954    fn governance_config_rejects_gates_field() {
2955        let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
2956        let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
2957        let gov = cfg.governance;
2958        assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
2959
2960        let round_trip = toml::to_string(&gov).expect("serialise gov");
2961        assert!(
2962            !round_trip.contains("gates"),
2963            "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
2964        );
2965        assert!(
2966            !round_trip.contains("[governance.gates]"),
2967            "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
2968        );
2969    }
2970}