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/// Spec scanning configuration.
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36pub struct SpecsConfig {
37    /// Directory containing spec files (relative to repo root).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub dir: Option<String>,
40    /// Spec format type: `"openspec"` or `"markdown"`.
41    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
42    pub spec_type: Option<String>,
43}
44
45/// Session logging configuration.
46#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
47pub struct LoggingConfig {
48    /// Whether session logging is enabled.
49    #[serde(default)]
50    pub enabled: bool,
51}
52
53/// Approval level governing how much autonomy an agent has when operating
54/// on the repository.
55///
56/// The variants are ordered from most conservative to most permissive:
57///
58/// - `Manual` — the agent must ask the user to approve every file write or
59///   shell command. Safest, but slowest.
60/// - `Auto` — the agent may perform routine edits without asking, but still
61///   defers for destructive or privileged operations. This is the default.
62/// - `FullAuto` — the agent is granted full unattended permissions,
63///   bypassing per-action approval. Only appropriate for trusted sandboxes.
64#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "kebab-case")]
66pub enum ApprovalLevel {
67    /// Prompt the user for every write or command.
68    Manual,
69    /// Allow routine edits without prompting, defer for destructive ops.
70    #[default]
71    Auto,
72    /// Grant full unattended permissions (skip approvals entirely).
73    FullAuto,
74}
75
76/// Dashboard configuration.
77#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
78pub struct DashboardConfig {
79    /// Whether to show the broker messages panel in the dashboard.
80    #[serde(default)]
81    pub show_message_log: bool,
82}
83
84/// Supervisor mode configuration.
85///
86/// Supervisor mode puts git-paw in front of the agent CLI as a coordinating
87/// layer that can enforce approval policy and run a verification command
88/// after each agent completes a task.
89#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SupervisorConfig {
91    /// Whether supervisor mode is enabled by default for this repo.
92    #[serde(default)]
93    pub enabled: bool,
94    /// Override the CLI used when launching the supervisor (e.g. `"claude"`).
95    /// `None` resolves to the normal CLI selection flow at runtime.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub cli: Option<String>,
98    /// Test command to run after each agent completes (e.g. `"just check"`).
99    /// `None` skips the verification step.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub test_command: Option<String>,
102    /// Approval policy applied to agent actions.
103    #[serde(default)]
104    pub agent_approval: ApprovalLevel,
105    /// Auto-approval configuration for safe permission prompts.
106    ///
107    /// When present, the supervisor automatically approves stalled agents
108    /// whose pending command matches an entry in the safe-command whitelist.
109    /// See [`AutoApproveConfig`] for the per-field semantics.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub auto_approve: Option<AutoApproveConfig>,
112}
113
114/// Coarse-grained policy preset that maps onto a known [`AutoApproveConfig`]
115/// shape.
116///
117/// The presets exist so users do not have to hand-craft a whitelist when
118/// they just want a sensible default for the project. The mapping is:
119///
120/// - `Off` — auto-approval is disabled regardless of other fields.
121/// - `Conservative` — auto-approve `cargo`/`git commit` style commands but
122///   strip `git push` and `curl` from the effective whitelist.
123/// - `Safe` — the built-in default; auto-approve everything in
124///   [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
125#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "kebab-case")]
127pub enum ApprovalLevelPreset {
128    /// Disable auto-approval entirely.
129    Off,
130    /// Approve only the most uncontroversial commands (no push/curl).
131    Conservative,
132    /// Approve every entry in the built-in safe-command list.
133    #[default]
134    Safe,
135}
136
137/// Configuration for the supervisor auto-approval feature.
138///
139/// Auto-approval detects permission prompts in stalled agent panes via
140/// `tmux capture-pane`, classifies the pending command, and dispatches the
141/// `BTab Down Enter` keystroke sequence when the command matches the
142/// whitelist.
143///
144/// Embedded as `Option<AutoApproveConfig>` on [`SupervisorConfig`] so
145/// existing configs without an `[supervisor.auto_approve]` table continue
146/// to round-trip identically.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub struct AutoApproveConfig {
149    /// Master enable flag. When `false`, no detection or approval runs.
150    #[serde(default = "AutoApproveConfig::default_enabled")]
151    pub enabled: bool,
152    /// Project-specific safe-command prefixes appended to the built-in
153    /// defaults from
154    /// [`default_safe_commands()`](crate::supervisor::auto_approve::default_safe_commands).
155    #[serde(default)]
156    pub safe_commands: Vec<String>,
157    /// Threshold (in seconds) of `last_seen` staleness before an agent in
158    /// `working` status is treated as stalled by the poll loop.
159    #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
160    pub stall_threshold_seconds: u64,
161    /// Coarse policy preset applied on top of the explicit fields.
162    ///
163    /// When the preset is `Off`, [`Self::enabled`] is forced to `false` by
164    /// [`Self::resolved`]. When the preset is `Conservative`, the effective
165    /// whitelist is the built-in defaults minus `git push` and `curl`
166    /// entries.
167    #[serde(default)]
168    pub approval_level: ApprovalLevelPreset,
169}
170
171impl Default for AutoApproveConfig {
172    fn default() -> Self {
173        Self {
174            enabled: Self::default_enabled(),
175            safe_commands: Vec::new(),
176            stall_threshold_seconds: Self::default_stall_threshold_seconds(),
177            approval_level: ApprovalLevelPreset::Safe,
178        }
179    }
180}
181
182impl AutoApproveConfig {
183    /// Minimum stall threshold in seconds. Anything lower is clamped to
184    /// avoid pathological poll loops.
185    pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
186
187    fn default_enabled() -> bool {
188        true
189    }
190
191    fn default_stall_threshold_seconds() -> u64 {
192        30
193    }
194
195    /// Returns a copy of this config with preset rules applied and the
196    /// stall threshold floor enforced.
197    ///
198    /// - When `approval_level == Off`, `enabled` is forced to `false`.
199    /// - When `stall_threshold_seconds < MIN_STALL_THRESHOLD_SECONDS`, the
200    ///   value is clamped and a warning is written to stderr.
201    #[must_use]
202    pub fn resolved(&self) -> Self {
203        let mut out = self.clone();
204        if out.approval_level == ApprovalLevelPreset::Off {
205            out.enabled = false;
206        }
207        if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
208            eprintln!(
209                "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
210                out.stall_threshold_seconds,
211                Self::MIN_STALL_THRESHOLD_SECONDS
212            );
213            out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
214        }
215        out
216    }
217
218    /// Returns the effective whitelist for this config, applying the preset
219    /// to the union of built-in defaults and user-configured `safe_commands`.
220    ///
221    /// - `Off` and `Safe` both return defaults plus configured extras.
222    /// - `Conservative` returns the same union with `git push` and any
223    ///   `curl` entries filtered out.
224    #[must_use]
225    pub fn effective_whitelist(&self) -> Vec<String> {
226        let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
227            .iter()
228            .map(|s| (*s).to_string())
229            .collect();
230        for extra in &self.safe_commands {
231            if !out.iter().any(|e| e == extra) {
232                out.push(extra.clone());
233            }
234        }
235        if self.approval_level == ApprovalLevelPreset::Conservative {
236            out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
237        }
238        out
239    }
240}
241
242/// Returns the CLI-specific permission flag for `cli` at the given approval
243/// `level`, or an empty string if the combination has no mapped flag.
244///
245/// # Examples
246///
247/// ```
248/// use git_paw::config::{approval_flags, ApprovalLevel};
249///
250/// assert_eq!(
251///     approval_flags("claude", &ApprovalLevel::FullAuto),
252///     "--dangerously-skip-permissions",
253/// );
254/// assert_eq!(
255///     approval_flags("codex", &ApprovalLevel::Auto),
256///     "--approval-mode=auto-edit",
257/// );
258/// assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
259/// assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
260/// ```
261#[must_use]
262pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
263    match (cli, level) {
264        ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
265        ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
266        ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
267        _ => "",
268    }
269}
270
271/// HTTP broker configuration for agent coordination.
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct BrokerConfig {
274    /// Whether the broker is enabled.
275    #[serde(default)]
276    pub enabled: bool,
277    /// TCP port the broker listens on.
278    #[serde(default = "BrokerConfig::default_port")]
279    pub port: u16,
280    /// Bind address for the broker.
281    #[serde(default = "BrokerConfig::default_bind")]
282    pub bind: String,
283}
284
285impl Default for BrokerConfig {
286    fn default() -> Self {
287        Self {
288            enabled: false,
289            port: 9119,
290            bind: "127.0.0.1".to_string(),
291        }
292    }
293}
294
295impl BrokerConfig {
296    /// Returns the full URL for the broker endpoint.
297    pub fn url(&self) -> String {
298        format!("http://{}:{}", self.bind, self.port)
299    }
300
301    fn default_port() -> u16 {
302        9119
303    }
304
305    fn default_bind() -> String {
306        "127.0.0.1".to_string()
307    }
308}
309
310/// Top-level git-paw configuration.
311///
312/// All fields are optional — absent config files produce empty defaults.
313#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
314pub struct PawConfig {
315    /// Default CLI to use when none is specified.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub default_cli: Option<String>,
318
319    /// Default CLI for `--from-specs` (bypasses picker when set).
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub default_spec_cli: Option<String>,
322
323    /// Prefix for spec-derived branch names (default: `"spec/"`).
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub branch_prefix: Option<String>,
326
327    /// Whether to enable tmux mouse mode for sessions.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub mouse: Option<bool>,
330
331    /// Custom CLI definitions keyed by name.
332    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
333    pub clis: HashMap<String, CustomCli>,
334
335    /// Named presets keyed by name.
336    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
337    pub presets: HashMap<String, Preset>,
338
339    /// Spec scanning configuration.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub specs: Option<SpecsConfig>,
342
343    /// Session logging configuration.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub logging: Option<LoggingConfig>,
346
347    /// Dashboard configuration.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub dashboard: Option<DashboardConfig>,
350
351    /// HTTP broker configuration.
352    #[serde(default)]
353    pub broker: BrokerConfig,
354
355    /// Supervisor mode configuration.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub supervisor: Option<SupervisorConfig>,
358}
359
360impl PawConfig {
361    /// Returns a new config that merges `overlay` on top of `self`.
362    ///
363    /// Scalar fields from `overlay` take precedence when present.
364    /// Map fields are merged with `overlay` entries winning on key collisions.
365    #[must_use]
366    pub fn merged_with(&self, overlay: &Self) -> Self {
367        let mut clis = self.clis.clone();
368        for (k, v) in &overlay.clis {
369            clis.insert(k.clone(), v.clone());
370        }
371
372        let mut presets = self.presets.clone();
373        for (k, v) in &overlay.presets {
374            presets.insert(k.clone(), v.clone());
375        }
376
377        Self {
378            default_cli: overlay
379                .default_cli
380                .clone()
381                .or_else(|| self.default_cli.clone()),
382            default_spec_cli: overlay
383                .default_spec_cli
384                .clone()
385                .or_else(|| self.default_spec_cli.clone()),
386            branch_prefix: overlay
387                .branch_prefix
388                .clone()
389                .or_else(|| self.branch_prefix.clone()),
390            mouse: overlay.mouse.or(self.mouse),
391            clis,
392            presets,
393            specs: overlay.specs.clone().or_else(|| self.specs.clone()),
394            logging: overlay.logging.clone().or_else(|| self.logging.clone()),
395            dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
396            broker: if overlay.broker == BrokerConfig::default() {
397                self.broker.clone()
398            } else {
399                overlay.broker.clone()
400            },
401            supervisor: overlay
402                .supervisor
403                .clone()
404                .or_else(|| self.supervisor.clone()),
405        }
406    }
407
408    /// Returns a preset by name, if it exists.
409    pub fn get_preset(&self, name: &str) -> Option<&Preset> {
410        self.presets.get(name)
411    }
412
413    /// Returns the dashboard configuration, if it exists.
414    pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
415        self.dashboard.as_ref()
416    }
417}
418
419/// Returns the path to the global config file (`~/.config/git-paw/config.toml`).
420pub fn global_config_path() -> Result<PathBuf, PawError> {
421    crate::dirs::config_dir()
422        .map(|d| d.join("git-paw").join("config.toml"))
423        .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
424}
425
426/// Returns the path to a repo-level config file (`.git-paw/config.toml`).
427pub fn repo_config_path(repo_root: &Path) -> PathBuf {
428    repo_root.join(".git-paw").join("config.toml")
429}
430
431/// Loads a [`PawConfig`] from a TOML file, returning `Ok(None)` if the file does not exist.
432fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
433    match fs::read_to_string(path) {
434        Ok(contents) => {
435            let config: PawConfig = toml::from_str(&contents)
436                .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
437            Ok(Some(config))
438        }
439        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
440        Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
441    }
442}
443
444/// Loads only the repo-level configuration (`.git-paw/config.toml`).
445///
446/// Returns defaults if the file does not exist. Useful when you need to
447/// update and save repo-level settings without clobbering global values.
448pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
449    Ok(load_config_file(&repo_config_path(repo_root))?.unwrap_or_default())
450}
451
452/// Loads the merged configuration for a repository.
453///
454/// Reads the global config and the per-repo config, merging them with
455/// repo settings taking precedence. Returns defaults if neither file exists.
456pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
457    let global_path = global_config_path()?;
458    load_config_from(&global_path, repo_root)
459}
460
461/// Loads merged config from an explicit global path and repo root.
462pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
463    let global = load_config_file(global_path)?.unwrap_or_default();
464    let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
465    Ok(global.merged_with(&repo))
466}
467
468/// Saves a [`PawConfig`] to the repo-level config file (`.git-paw/config.toml`).
469pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
470    save_config_to(&repo_config_path(repo_root), config)
471}
472
473/// Writes a [`PawConfig`] to a TOML file atomically (temp file + rename).
474fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
475    let dir = path
476        .parent()
477        .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
478    fs::create_dir_all(dir)
479        .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
480
481    let contents =
482        toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
483
484    // Atomic write: temp file + rename
485    let tmp = path.with_extension("toml.tmp");
486    fs::write(&tmp, &contents)
487        .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
488    fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
489
490    Ok(())
491}
492
493/// Adds a custom CLI to the global config.
494///
495/// If `command` is not an absolute path, it is resolved via PATH using `which`.
496pub fn add_custom_cli(
497    name: &str,
498    command: &str,
499    display_name: Option<&str>,
500) -> Result<(), PawError> {
501    add_custom_cli_to(&global_config_path()?, name, command, display_name)
502}
503
504/// Adds a custom CLI to the config at the given path.
505///
506/// If `command` is not an absolute path, it is resolved via PATH using `which`.
507pub fn add_custom_cli_to(
508    config_path: &Path,
509    name: &str,
510    command: &str,
511    display_name: Option<&str>,
512) -> Result<(), PawError> {
513    let resolved_command = if Path::new(command).is_absolute() {
514        command.to_string()
515    } else {
516        which::which(command)
517            .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
518            .to_string_lossy()
519            .into_owned()
520    };
521
522    let mut config = load_config_file(config_path)?.unwrap_or_default();
523
524    config.clis.insert(
525        name.to_string(),
526        CustomCli {
527            command: resolved_command,
528            display_name: display_name.map(String::from),
529        },
530    );
531
532    save_config_to(config_path, &config)
533}
534
535/// Returns a default `config.toml` string with sensible defaults and
536/// commented-out v0.2.0 fields for discoverability.
537pub fn generate_default_config() -> String {
538    r#"# git-paw configuration
539# See https://github.com/bearicorn/git-paw for documentation.
540
541# Pre-select a CLI in the interactive picker (user can still change).
542# Omit to show the full picker with no default.
543# default_cli = ""
544
545# Enable tmux mouse mode for sessions (default: true).
546# mouse = true
547
548# Bypass the CLI picker entirely for --from-specs mode.
549# Omit to prompt or use per-spec paw_cli fields.
550# default_spec_cli = ""
551
552# Prefix for spec-derived branch names (default: "spec/" ).
553# branch_prefix = "spec/"
554
555# Dashboard message log configuration.
556# [dashboard]
557# show_message_log = false
558
559# Spec scanning configuration.
560# [specs]
561# dir = "specs"
562#
563# OpenSpec format (directory-based, default):
564# type = "openspec"
565#
566# Markdown format (frontmatter-based):
567# type = "markdown"
568# Each .md file uses YAML frontmatter fields:
569#   paw_status  — "pending" | "done" | "in-progress" (required)
570#   paw_branch  — branch name suffix (optional, falls back to filename)
571#   paw_cli     — CLI override for this spec (optional)
572
573# Session logging configuration.
574# [logging]
575# enabled = false
576
577# HTTP broker for agent coordination (requires --broker flag on start).
578# [broker]
579# enabled = true
580# port = 9119
581# bind = "127.0.0.1"
582
583# Supervisor mode — git-paw acts as a coordinating layer in front of the
584# agent CLI, enforcing approval policy and optionally running a test
585# command after each agent completes.
586# [supervisor]
587# enabled = true
588# cli = "claude"
589# test_command = "just check"
590# agent_approval = "auto"  # one of: "manual", "auto", "full-auto"
591
592# Custom CLI definitions.
593# [clis.my-agent]
594# command = "/usr/local/bin/my-agent"
595# display_name = "My Agent"
596
597# Named presets for quick launches.
598# [presets.my-preset]
599# branches = ["feat/api", "fix/db"]
600# cli = ""
601"#
602    .to_string()
603}
604
605/// Removes a custom CLI from the global config.
606///
607/// Returns `PawError::CliNotFound` if the name is not present in the config.
608pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
609    remove_custom_cli_from(&global_config_path()?, name)
610}
611
612/// Removes a custom CLI from the config at the given path.
613///
614/// Returns `PawError::CliNotFound` if the name is not present in the config.
615pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
616    let mut config = load_config_file(config_path)?.unwrap_or_default();
617
618    if config.clis.remove(name).is_none() {
619        return Err(PawError::CliNotFound(name.to_string()));
620    }
621
622    save_config_to(config_path, &config)
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use tempfile::TempDir;
629
630    fn write_file(path: &Path, content: &str) {
631        if let Some(parent) = path.parent() {
632            fs::create_dir_all(parent).unwrap();
633        }
634        fs::write(path, content).unwrap();
635    }
636
637    // --- Parsing behavior ---
638
639    #[test]
640    fn parses_config_with_all_fields() {
641        let tmp = TempDir::new().unwrap();
642        let path = tmp.path().join("config.toml");
643        write_file(
644            &path,
645            r#"
646default_cli = "claude"
647mouse = false
648default_spec_cli = "gemini"
649branch_prefix = "spec/"
650
651[clis.my-agent]
652command = "/usr/local/bin/my-agent"
653display_name = "My Agent"
654
655[clis.local-llm]
656command = "ollama-code"
657
658[presets.backend]
659branches = ["feature/api", "fix/db"]
660cli = "claude"
661
662[specs]
663dir = "my-specs"
664type = "openspec"
665
666[logging]
667enabled = true
668"#,
669        );
670
671        let config = load_config_file(&path).unwrap().unwrap();
672        assert_eq!(config.default_cli.as_deref(), Some("claude"));
673        assert_eq!(config.mouse, Some(false));
674        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
675        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
676        assert_eq!(config.clis.len(), 2);
677        assert_eq!(
678            config.clis["my-agent"].display_name.as_deref(),
679            Some("My Agent")
680        );
681        assert_eq!(config.clis["local-llm"].command, "ollama-code");
682        assert_eq!(config.presets["backend"].cli, "claude");
683        assert_eq!(
684            config.presets["backend"].branches,
685            vec!["feature/api", "fix/db"]
686        );
687        let specs = config.specs.unwrap();
688        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
689        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
690        let logging = config.logging.unwrap();
691        assert!(logging.enabled);
692    }
693
694    #[test]
695    fn all_fields_are_optional() {
696        let tmp = TempDir::new().unwrap();
697        let path = tmp.path().join("config.toml");
698        write_file(&path, "default_cli = \"gemini\"\n");
699
700        let config = load_config_file(&path).unwrap().unwrap();
701        assert_eq!(config.default_cli.as_deref(), Some("gemini"));
702        assert_eq!(config.mouse, None);
703        assert!(config.clis.is_empty());
704        assert!(config.presets.is_empty());
705    }
706
707    #[test]
708    fn returns_defaults_when_no_files_exist() {
709        let tmp = TempDir::new().unwrap();
710        let global_path = tmp.path().join("nonexistent").join("config.toml");
711        let repo_root = tmp.path().join("repo");
712        fs::create_dir_all(&repo_root).unwrap();
713
714        let config = load_config_from(&global_path, &repo_root).unwrap();
715        assert_eq!(config.default_cli, None);
716        assert_eq!(config.mouse, None);
717        assert!(config.clis.is_empty());
718        assert!(config.presets.is_empty());
719    }
720
721    #[test]
722    fn reports_error_for_invalid_toml() {
723        let tmp = TempDir::new().unwrap();
724        let path = tmp.path().join("bad.toml");
725        write_file(&path, "this is not [valid toml");
726
727        let err = load_config_file(&path).unwrap_err();
728        assert!(err.to_string().contains("bad.toml"));
729    }
730
731    // --- Merge behavior (through file I/O) ---
732
733    #[test]
734    fn repo_config_overrides_global_scalars() {
735        let tmp = TempDir::new().unwrap();
736        let global_path = tmp.path().join("global").join("config.toml");
737        let repo_root = tmp.path().join("repo");
738        fs::create_dir_all(&repo_root).unwrap();
739
740        write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
741        write_file(
742            &repo_config_path(&repo_root),
743            "default_cli = \"gemini\"\n", // mouse intentionally absent
744        );
745
746        let config = load_config_from(&global_path, &repo_root).unwrap();
747        assert_eq!(config.default_cli.as_deref(), Some("gemini")); // repo wins
748        assert_eq!(config.mouse, Some(true)); // global preserved when repo absent
749    }
750
751    #[test]
752    fn repo_config_merges_cli_maps() {
753        let tmp = TempDir::new().unwrap();
754        let global_path = tmp.path().join("global").join("config.toml");
755        let repo_root = tmp.path().join("repo");
756        fs::create_dir_all(&repo_root).unwrap();
757
758        write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
759        write_file(
760            &repo_config_path(&repo_root),
761            "[clis.agent-b]\ncommand = \"/bin/b\"\n",
762        );
763
764        let config = load_config_from(&global_path, &repo_root).unwrap();
765        assert_eq!(config.clis.len(), 2);
766        assert!(config.clis.contains_key("agent-a"));
767        assert!(config.clis.contains_key("agent-b"));
768    }
769
770    #[test]
771    fn repo_cli_overrides_global_cli_with_same_name() {
772        let tmp = TempDir::new().unwrap();
773        let global_path = tmp.path().join("global").join("config.toml");
774        let repo_root = tmp.path().join("repo");
775        fs::create_dir_all(&repo_root).unwrap();
776
777        write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
778        write_file(
779            &repo_config_path(&repo_root),
780            "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
781        );
782
783        let config = load_config_from(&global_path, &repo_root).unwrap();
784        assert_eq!(config.clis["my-agent"].command, "/new/path");
785        assert_eq!(
786            config.clis["my-agent"].display_name.as_deref(),
787            Some("Overridden")
788        );
789    }
790
791    #[test]
792    fn load_config_from_reads_global_file_when_no_repo() {
793        let tmp = TempDir::new().unwrap();
794        let global_path = tmp.path().join("global").join("config.toml");
795        let repo_root = tmp.path().join("repo");
796        fs::create_dir_all(&repo_root).unwrap();
797
798        write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
799        // No .git-paw/config.toml in repo_root
800
801        let config = load_config_from(&global_path, &repo_root).unwrap();
802        assert_eq!(config.default_cli.as_deref(), Some("claude"));
803        assert_eq!(config.mouse, Some(false));
804    }
805
806    #[test]
807    fn load_config_from_reads_repo_file_when_no_global() {
808        let tmp = TempDir::new().unwrap();
809        let global_path = tmp.path().join("nonexistent").join("config.toml");
810        let repo_root = tmp.path().join("repo");
811        fs::create_dir_all(&repo_root).unwrap();
812
813        write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
814
815        let config = load_config_from(&global_path, &repo_root).unwrap();
816        assert_eq!(config.default_cli.as_deref(), Some("codex"));
817    }
818
819    // --- Preset behavior ---
820
821    #[test]
822    fn preset_accessible_by_name() {
823        let tmp = TempDir::new().unwrap();
824        let global_path = tmp.path().join("global").join("config.toml");
825        let repo_root = tmp.path().join("repo");
826        fs::create_dir_all(&repo_root).unwrap();
827
828        write_file(
829            &repo_config_path(&repo_root),
830            "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
831        );
832
833        let config = load_config_from(&global_path, &repo_root).unwrap();
834        let preset = config.get_preset("backend").unwrap();
835        assert_eq!(preset.cli, "claude");
836        assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
837    }
838
839    #[test]
840    fn preset_returns_none_when_not_in_config() {
841        let tmp = TempDir::new().unwrap();
842        let global_path = tmp.path().join("config.toml");
843        write_file(&global_path, "default_cli = \"claude\"\n");
844
845        let config = load_config_file(&global_path).unwrap().unwrap();
846        assert!(config.get_preset("nonexistent").is_none());
847    }
848
849    // --- add_custom_cli behavior ---
850
851    #[test]
852    fn add_cli_writes_to_config_file() {
853        let tmp = TempDir::new().unwrap();
854        let config_path = tmp.path().join("git-paw").join("config.toml");
855
856        // Add a CLI with an absolute path (no PATH resolution needed)
857        add_custom_cli_to(
858            &config_path,
859            "my-agent",
860            "/usr/local/bin/my-agent",
861            Some("My Agent"),
862        )
863        .unwrap();
864
865        // Verify by loading the file back
866        let config = load_config_file(&config_path).unwrap().unwrap();
867        assert_eq!(config.clis.len(), 1);
868        assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
869        assert_eq!(
870            config.clis["my-agent"].display_name.as_deref(),
871            Some("My Agent")
872        );
873    }
874
875    #[test]
876    fn add_cli_preserves_existing_entries() {
877        let tmp = TempDir::new().unwrap();
878        let config_path = tmp.path().join("git-paw").join("config.toml");
879
880        add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
881        add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
882
883        let config = load_config_file(&config_path).unwrap().unwrap();
884        assert_eq!(config.clis.len(), 2);
885        assert!(config.clis.contains_key("first"));
886        assert!(config.clis.contains_key("second"));
887    }
888
889    #[test]
890    fn add_cli_errors_when_command_not_on_path() {
891        let tmp = TempDir::new().unwrap();
892        let config_path = tmp.path().join("config.toml");
893
894        let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
895            .unwrap_err();
896        assert!(err.to_string().contains("not found on PATH"));
897    }
898
899    // --- remove_custom_cli behavior ---
900
901    #[test]
902    fn remove_cli_deletes_entry_from_config_file() {
903        let tmp = TempDir::new().unwrap();
904        let config_path = tmp.path().join("git-paw").join("config.toml");
905
906        // Set up: add two CLIs
907        add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
908        add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
909
910        // Act: remove one
911        remove_custom_cli_from(&config_path, "remove-me").unwrap();
912
913        // Verify: only the kept CLI remains
914        let config = load_config_file(&config_path).unwrap().unwrap();
915        assert_eq!(config.clis.len(), 1);
916        assert!(config.clis.contains_key("keep-me"));
917        assert!(!config.clis.contains_key("remove-me"));
918    }
919
920    #[test]
921    fn remove_nonexistent_cli_returns_cli_not_found_error() {
922        let tmp = TempDir::new().unwrap();
923        let config_path = tmp.path().join("config.toml");
924        // Empty config file
925        write_file(&config_path, "");
926
927        let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
928        match err {
929            PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
930            other => panic!("expected CliNotFound, got: {other}"),
931        }
932    }
933
934    #[test]
935    fn remove_cli_from_empty_config_returns_error() {
936        let tmp = TempDir::new().unwrap();
937        let config_path = tmp.path().join("config.toml");
938        // No file at all
939
940        let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
941        match err {
942            PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
943            other => panic!("expected CliNotFound, got: {other}"),
944        }
945    }
946
947    // --- Round-trip: config survives write + read ---
948
949    // --- default_spec_cli behavior ---
950
951    #[test]
952    fn parses_default_spec_cli_when_present() {
953        let tmp = TempDir::new().unwrap();
954        let path = tmp.path().join("config.toml");
955        write_file(&path, "default_spec_cli = \"claude\"\n");
956
957        let config = load_config_file(&path).unwrap().unwrap();
958        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
959    }
960
961    #[test]
962    fn default_spec_cli_defaults_to_none() {
963        let tmp = TempDir::new().unwrap();
964        let path = tmp.path().join("config.toml");
965        write_file(&path, "default_cli = \"claude\"\n");
966
967        let config = load_config_file(&path).unwrap().unwrap();
968        assert_eq!(config.default_spec_cli, None);
969    }
970
971    #[test]
972    fn repo_overrides_global_default_spec_cli() {
973        let tmp = TempDir::new().unwrap();
974        let global_path = tmp.path().join("global").join("config.toml");
975        let repo_root = tmp.path().join("repo");
976        fs::create_dir_all(&repo_root).unwrap();
977
978        write_file(&global_path, "default_spec_cli = \"claude\"\n");
979        write_file(
980            &repo_config_path(&repo_root),
981            "default_spec_cli = \"gemini\"\n",
982        );
983
984        let config = load_config_from(&global_path, &repo_root).unwrap();
985        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
986    }
987
988    #[test]
989    fn global_default_spec_cli_preserved_when_repo_absent() {
990        let tmp = TempDir::new().unwrap();
991        let global_path = tmp.path().join("global").join("config.toml");
992        let repo_root = tmp.path().join("repo");
993        fs::create_dir_all(&repo_root).unwrap();
994
995        write_file(&global_path, "default_spec_cli = \"claude\"\n");
996
997        let config = load_config_from(&global_path, &repo_root).unwrap();
998        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
999    }
1000
1001    // --- Round-trip: config survives write + read ---
1002
1003    #[test]
1004    fn config_survives_save_and_load() {
1005        let tmp = TempDir::new().unwrap();
1006        let config_path = tmp.path().join("config.toml");
1007
1008        let original = PawConfig {
1009            default_cli: Some("claude".into()),
1010            default_spec_cli: None,
1011            branch_prefix: None,
1012            mouse: Some(true),
1013            clis: HashMap::from([(
1014                "test".into(),
1015                CustomCli {
1016                    command: "/bin/test".into(),
1017                    display_name: Some("Test CLI".into()),
1018                },
1019            )]),
1020            presets: HashMap::from([(
1021                "dev".into(),
1022                Preset {
1023                    branches: vec!["main".into()],
1024                    cli: "claude".into(),
1025                },
1026            )]),
1027            specs: None,
1028            logging: None,
1029            dashboard: None,
1030            broker: BrokerConfig::default(),
1031            supervisor: None,
1032        };
1033
1034        save_config_to(&config_path, &original).unwrap();
1035        let loaded = load_config_file(&config_path).unwrap().unwrap();
1036        assert_eq!(original, loaded);
1037    }
1038
1039    // --- Gap #1: Parse [specs] section with populated fields ---
1040
1041    #[test]
1042    fn parses_specs_section_with_populated_fields() {
1043        let tmp = TempDir::new().unwrap();
1044        let path = tmp.path().join("config.toml");
1045        write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
1046
1047        let config = load_config_file(&path).unwrap().unwrap();
1048        let specs = config.specs.unwrap();
1049        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1050        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1051    }
1052
1053    // --- Gap #2: Parse [logging] section with enabled ---
1054
1055    #[test]
1056    fn parses_logging_section_with_enabled() {
1057        let tmp = TempDir::new().unwrap();
1058        let path = tmp.path().join("config.toml");
1059        write_file(&path, "[logging]\nenabled = true\n");
1060
1061        let config = load_config_file(&path).unwrap().unwrap();
1062        let logging = config.logging.unwrap();
1063        assert!(logging.enabled);
1064    }
1065
1066    // --- Gap #3: Round-trip with specs and logging populated ---
1067
1068    #[test]
1069    fn round_trip_with_specs_and_logging() {
1070        let tmp = TempDir::new().unwrap();
1071        let config_path = tmp.path().join("config.toml");
1072
1073        let original = PawConfig {
1074            specs: Some(SpecsConfig {
1075                dir: Some("specs".into()),
1076                spec_type: Some("openspec".into()),
1077            }),
1078            logging: Some(LoggingConfig { enabled: true }),
1079            ..Default::default()
1080        };
1081
1082        save_config_to(&config_path, &original).unwrap();
1083        let loaded = load_config_file(&config_path).unwrap().unwrap();
1084        assert_eq!(original, loaded);
1085        assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
1086        assert!(loaded.logging.unwrap().enabled);
1087    }
1088
1089    // --- Gap #4: Generated config is valid TOML ---
1090
1091    #[test]
1092    fn generated_default_config_is_valid_toml() {
1093        let raw = generate_default_config();
1094        let stripped: String = raw
1095            .lines()
1096            .filter(|line| !line.trim_start().starts_with('#'))
1097            .collect::<Vec<&str>>()
1098            .join("\n");
1099
1100        let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
1101        assert!(
1102            parsed.is_ok(),
1103            "generated config with comments stripped should be valid TOML, got: {:?}",
1104            parsed.unwrap_err()
1105        );
1106    }
1107
1108    // --- Gap #5: branch_prefix merge ---
1109
1110    #[test]
1111    fn branch_prefix_repo_overrides_global() {
1112        let tmp = TempDir::new().unwrap();
1113        let global_path = tmp.path().join("global").join("config.toml");
1114        let repo_root = tmp.path().join("repo");
1115        fs::create_dir_all(&repo_root).unwrap();
1116
1117        write_file(&global_path, "branch_prefix = \"feat/\"\n");
1118        write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
1119
1120        let config = load_config_from(&global_path, &repo_root).unwrap();
1121        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1122    }
1123
1124    #[test]
1125    fn generated_default_config_contains_commented_examples() {
1126        let output = generate_default_config();
1127        assert!(
1128            output.contains("default_spec_cli"),
1129            "should contain default_spec_cli"
1130        );
1131        assert!(
1132            output.contains("branch_prefix"),
1133            "should contain branch_prefix"
1134        );
1135        assert!(output.contains("[specs]"), "should contain [specs]");
1136        assert!(output.contains("[logging]"), "should contain [logging]");
1137        assert!(output.contains("[broker]"), "should contain [broker]");
1138    }
1139
1140    // --- BrokerConfig ---
1141
1142    #[test]
1143    fn broker_config_defaults() {
1144        let config = BrokerConfig::default();
1145        assert!(!config.enabled);
1146        assert_eq!(config.port, 9119);
1147        assert_eq!(config.bind, "127.0.0.1");
1148    }
1149
1150    #[test]
1151    fn broker_config_url() {
1152        let config = BrokerConfig::default();
1153        assert_eq!(config.url(), "http://127.0.0.1:9119");
1154
1155        let custom = BrokerConfig {
1156            enabled: true,
1157            port: 8080,
1158            bind: "0.0.0.0".to_string(),
1159        };
1160        assert_eq!(custom.url(), "http://0.0.0.0:8080");
1161    }
1162
1163    #[test]
1164    fn empty_config_gets_broker_defaults() {
1165        let tmp = TempDir::new().unwrap();
1166        let path = tmp.path().join("config.toml");
1167        write_file(&path, "");
1168
1169        let config = load_config_file(&path).unwrap().unwrap();
1170        assert!(!config.broker.enabled);
1171        assert_eq!(config.broker.port, 9119);
1172        assert_eq!(config.broker.bind, "127.0.0.1");
1173    }
1174
1175    #[test]
1176    fn parses_full_broker_section() {
1177        let tmp = TempDir::new().unwrap();
1178        let path = tmp.path().join("config.toml");
1179        write_file(
1180            &path,
1181            "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
1182        );
1183
1184        let config = load_config_file(&path).unwrap().unwrap();
1185        assert!(config.broker.enabled);
1186        assert_eq!(config.broker.port, 8080);
1187        assert_eq!(config.broker.bind, "0.0.0.0");
1188    }
1189
1190    #[test]
1191    fn parses_partial_broker_section() {
1192        let tmp = TempDir::new().unwrap();
1193        let path = tmp.path().join("config.toml");
1194        write_file(&path, "[broker]\nenabled = true\n");
1195
1196        let config = load_config_file(&path).unwrap().unwrap();
1197        assert!(config.broker.enabled);
1198        assert_eq!(config.broker.port, 9119);
1199        assert_eq!(config.broker.bind, "127.0.0.1");
1200    }
1201
1202    // --- SupervisorConfig ---
1203
1204    #[test]
1205    fn supervisor_is_none_when_section_absent() {
1206        let tmp = TempDir::new().unwrap();
1207        let path = tmp.path().join("config.toml");
1208        write_file(&path, "default_cli = \"claude\"\n");
1209
1210        let config = load_config_file(&path).unwrap().unwrap();
1211        assert!(config.supervisor.is_none());
1212    }
1213
1214    #[test]
1215    fn parses_full_supervisor_section() {
1216        let tmp = TempDir::new().unwrap();
1217        let path = tmp.path().join("config.toml");
1218        write_file(
1219            &path,
1220            "[supervisor]\n\
1221             enabled = true\n\
1222             cli = \"claude\"\n\
1223             test_command = \"just check\"\n\
1224             agent_approval = \"full-auto\"\n",
1225        );
1226
1227        let config = load_config_file(&path).unwrap().unwrap();
1228        let supervisor = config.supervisor.unwrap();
1229        assert!(supervisor.enabled);
1230        assert_eq!(supervisor.cli.as_deref(), Some("claude"));
1231        assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
1232        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1233    }
1234
1235    #[test]
1236    fn parses_partial_supervisor_section() {
1237        let tmp = TempDir::new().unwrap();
1238        let path = tmp.path().join("config.toml");
1239        write_file(&path, "[supervisor]\nenabled = true\n");
1240
1241        let config = load_config_file(&path).unwrap().unwrap();
1242        let supervisor = config.supervisor.unwrap();
1243        assert!(supervisor.enabled);
1244        assert_eq!(supervisor.cli, None);
1245        assert_eq!(supervisor.test_command, None);
1246        assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
1247    }
1248
1249    #[test]
1250    fn rejects_invalid_approval_level() {
1251        let tmp = TempDir::new().unwrap();
1252        let path = tmp.path().join("config.toml");
1253        write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
1254
1255        let err = load_config_file(&path).unwrap_err();
1256        assert!(
1257            err.to_string().contains("yolo"),
1258            "error should mention invalid value, got: {err}"
1259        );
1260    }
1261
1262    #[test]
1263    fn supervisor_round_trips_through_save_and_load() {
1264        let tmp = TempDir::new().unwrap();
1265        let config_path = tmp.path().join("config.toml");
1266
1267        let original = PawConfig {
1268            supervisor: Some(SupervisorConfig {
1269                enabled: true,
1270                cli: Some("claude".into()),
1271                test_command: Some("just check".into()),
1272                agent_approval: ApprovalLevel::FullAuto,
1273                auto_approve: None,
1274            }),
1275            ..Default::default()
1276        };
1277
1278        save_config_to(&config_path, &original).unwrap();
1279        let loaded = load_config_file(&config_path).unwrap().unwrap();
1280        assert_eq!(loaded.supervisor, original.supervisor);
1281    }
1282
1283    #[test]
1284    fn existing_v030_config_loads_without_supervisor() {
1285        let tmp = TempDir::new().unwrap();
1286        let path = tmp.path().join("config.toml");
1287        write_file(
1288            &path,
1289            "default_cli = \"claude\"\n\
1290             mouse = true\n\
1291             [broker]\n\
1292             enabled = true\n\
1293             [logging]\n\
1294             enabled = false\n",
1295        );
1296
1297        let config = load_config_file(&path).unwrap().unwrap();
1298        assert_eq!(config.default_cli.as_deref(), Some("claude"));
1299        assert!(config.broker.enabled);
1300        assert!(config.supervisor.is_none());
1301    }
1302
1303    #[test]
1304    fn generated_default_config_contains_commented_supervisor_section() {
1305        let output = generate_default_config();
1306        assert!(output.contains("[supervisor]"));
1307        assert!(output.contains("enabled"));
1308        assert!(output.contains("test_command"));
1309        assert!(output.contains("agent_approval"));
1310    }
1311
1312    // --- DashboardConfig ---
1313
1314    #[test]
1315    fn dashboard_config_defaults_to_disabled() {
1316        let config = DashboardConfig::default();
1317        assert!(!config.show_message_log);
1318    }
1319
1320    #[test]
1321    fn parses_dashboard_section_with_show_message_log() {
1322        let tmp = TempDir::new().unwrap();
1323        let path = tmp.path().join("config.toml");
1324        write_file(&path, "[dashboard]\nshow_message_log = true\n");
1325
1326        let config = load_config_file(&path).unwrap().unwrap();
1327        let dashboard = config.dashboard.unwrap();
1328        assert!(dashboard.show_message_log);
1329    }
1330
1331    #[test]
1332    fn dashboard_is_none_when_section_absent() {
1333        let tmp = TempDir::new().unwrap();
1334        let path = tmp.path().join("config.toml");
1335        write_file(&path, "default_cli = \"claude\"\n");
1336
1337        let config = load_config_file(&path).unwrap().unwrap();
1338        assert!(config.dashboard.is_none());
1339    }
1340
1341    #[test]
1342    fn dashboard_merge_repo_wins() {
1343        let tmp = TempDir::new().unwrap();
1344        let global_path = tmp.path().join("global").join("config.toml");
1345        let repo_root = tmp.path().join("repo");
1346        fs::create_dir_all(&repo_root).unwrap();
1347
1348        write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
1349        write_file(
1350            &repo_config_path(&repo_root),
1351            "[dashboard]\nshow_message_log = true\n",
1352        );
1353
1354        let config = load_config_from(&global_path, &repo_root).unwrap();
1355        let dashboard = config.dashboard.unwrap();
1356        assert!(dashboard.show_message_log);
1357    }
1358
1359    #[test]
1360    fn dashboard_round_trip_through_save_and_load() {
1361        let tmp = TempDir::new().unwrap();
1362        let config_path = tmp.path().join("config.toml");
1363
1364        let original = PawConfig {
1365            dashboard: Some(DashboardConfig {
1366                show_message_log: true,
1367            }),
1368            ..Default::default()
1369        };
1370
1371        save_config_to(&config_path, &original).unwrap();
1372        let loaded = load_config_file(&config_path).unwrap().unwrap();
1373        assert_eq!(loaded.dashboard, original.dashboard);
1374        assert!(loaded.dashboard.unwrap().show_message_log);
1375    }
1376
1377    #[test]
1378    fn get_dashboard_returns_none_when_not_configured() {
1379        let config = PawConfig::default();
1380        assert!(config.get_dashboard().is_none());
1381    }
1382
1383    #[test]
1384    fn get_dashboard_returns_config_when_present() {
1385        let config = PawConfig {
1386            dashboard: Some(DashboardConfig {
1387                show_message_log: true,
1388            }),
1389            ..Default::default()
1390        };
1391        let dashboard = config.get_dashboard().unwrap();
1392        assert!(dashboard.show_message_log);
1393    }
1394
1395    // --- approval_flags mapping ---
1396
1397    #[test]
1398    fn approval_flags_claude_full_auto() {
1399        assert_eq!(
1400            approval_flags("claude", &ApprovalLevel::FullAuto),
1401            "--dangerously-skip-permissions"
1402        );
1403    }
1404
1405    #[test]
1406    fn approval_flags_codex_auto() {
1407        assert_eq!(
1408            approval_flags("codex", &ApprovalLevel::Auto),
1409            "--approval-mode=auto-edit"
1410        );
1411    }
1412
1413    #[test]
1414    fn approval_flags_codex_full_auto() {
1415        assert_eq!(
1416            approval_flags("codex", &ApprovalLevel::FullAuto),
1417            "--approval-mode=full-auto"
1418        );
1419    }
1420
1421    #[test]
1422    fn approval_flags_unknown_cli_is_empty() {
1423        assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
1424    }
1425
1426    #[test]
1427    fn approval_flags_manual_is_empty() {
1428        assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
1429        assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
1430    }
1431
1432    #[test]
1433    fn approval_flags_is_deterministic() {
1434        let first = approval_flags("claude", &ApprovalLevel::FullAuto);
1435        let second = approval_flags("claude", &ApprovalLevel::FullAuto);
1436        assert_eq!(first, second);
1437    }
1438
1439    #[test]
1440    fn supervisor_merge_repo_wins() {
1441        let tmp = TempDir::new().unwrap();
1442        let global_path = tmp.path().join("global").join("config.toml");
1443        let repo_root = tmp.path().join("repo");
1444        fs::create_dir_all(&repo_root).unwrap();
1445
1446        write_file(
1447            &global_path,
1448            "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
1449        );
1450        write_file(
1451            &repo_config_path(&repo_root),
1452            "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
1453        );
1454
1455        let config = load_config_from(&global_path, &repo_root).unwrap();
1456        let supervisor = config.supervisor.unwrap();
1457        assert!(supervisor.enabled);
1458        assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1459    }
1460
1461    #[test]
1462    fn broker_config_round_trip() {
1463        let tmp = TempDir::new().unwrap();
1464        let config_path = tmp.path().join("config.toml");
1465
1466        let original = PawConfig {
1467            broker: BrokerConfig {
1468                enabled: true,
1469                port: 9200,
1470                bind: "127.0.0.1".to_string(),
1471            },
1472            ..Default::default()
1473        };
1474
1475        save_config_to(&config_path, &original).unwrap();
1476        let loaded = load_config_file(&config_path).unwrap().unwrap();
1477        assert_eq!(loaded.broker.enabled, original.broker.enabled);
1478        assert_eq!(loaded.broker.port, original.broker.port);
1479        assert_eq!(loaded.broker.bind, original.broker.bind);
1480    }
1481
1482    // --- AutoApproveConfig (auto-approve-patterns / approval-configuration) ---
1483
1484    #[test]
1485    fn auto_approve_defaults_match_spec() {
1486        let cfg = AutoApproveConfig::default();
1487        assert!(cfg.enabled, "enabled defaults to true");
1488        assert!(
1489            cfg.safe_commands.is_empty(),
1490            "safe_commands defaults to empty"
1491        );
1492        assert_eq!(cfg.stall_threshold_seconds, 30);
1493        assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
1494    }
1495
1496    #[test]
1497    fn auto_approve_section_absent_keeps_supervisor_simple() {
1498        let tmp = TempDir::new().unwrap();
1499        let path = tmp.path().join("config.toml");
1500        write_file(&path, "[supervisor]\nenabled = true\n");
1501        let config = load_config_file(&path).unwrap().unwrap();
1502        let supervisor = config.supervisor.unwrap();
1503        assert!(supervisor.auto_approve.is_none());
1504    }
1505
1506    #[test]
1507    fn auto_approve_section_parses_full_body() {
1508        let tmp = TempDir::new().unwrap();
1509        let path = tmp.path().join("config.toml");
1510        write_file(
1511            &path,
1512            "[supervisor]\n\
1513             enabled = true\n\
1514             [supervisor.auto_approve]\n\
1515             enabled = false\n\
1516             safe_commands = [\"just smoke\"]\n\
1517             stall_threshold_seconds = 60\n\
1518             approval_level = \"conservative\"\n",
1519        );
1520        let config = load_config_file(&path).unwrap().unwrap();
1521        let aa = config.supervisor.unwrap().auto_approve.unwrap();
1522        assert!(!aa.enabled);
1523        assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
1524        assert_eq!(aa.stall_threshold_seconds, 60);
1525        assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
1526    }
1527
1528    #[test]
1529    fn auto_approve_enabled_defaults_to_true_when_omitted() {
1530        let tmp = TempDir::new().unwrap();
1531        let path = tmp.path().join("config.toml");
1532        write_file(
1533            &path,
1534            "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
1535        );
1536        let config = load_config_file(&path).unwrap().unwrap();
1537        let aa = config.supervisor.unwrap().auto_approve.unwrap();
1538        assert!(aa.enabled, "enabled should default to true");
1539    }
1540
1541    #[test]
1542    fn auto_approve_off_preset_forces_disabled() {
1543        let cfg = AutoApproveConfig {
1544            enabled: true,
1545            approval_level: ApprovalLevelPreset::Off,
1546            ..AutoApproveConfig::default()
1547        };
1548        let resolved = cfg.resolved();
1549        assert!(!resolved.enabled, "Off preset must force enabled = false");
1550    }
1551
1552    #[test]
1553    fn auto_approve_threshold_floor_clamps() {
1554        let cfg = AutoApproveConfig {
1555            stall_threshold_seconds: 0,
1556            ..AutoApproveConfig::default()
1557        };
1558        let resolved = cfg.resolved();
1559        assert_eq!(
1560            resolved.stall_threshold_seconds,
1561            AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
1562        );
1563    }
1564
1565    #[test]
1566    fn auto_approve_safe_preset_keeps_defaults() {
1567        let cfg = AutoApproveConfig {
1568            approval_level: ApprovalLevelPreset::Safe,
1569            ..AutoApproveConfig::default()
1570        };
1571        let wl = cfg.effective_whitelist();
1572        assert!(wl.iter().any(|c| c == "cargo test"));
1573        assert!(wl.iter().any(|c| c == "git push"));
1574        assert!(wl.iter().any(|c| c.starts_with("curl")));
1575    }
1576
1577    #[test]
1578    fn auto_approve_conservative_drops_push_and_curl() {
1579        let cfg = AutoApproveConfig {
1580            approval_level: ApprovalLevelPreset::Conservative,
1581            ..AutoApproveConfig::default()
1582        };
1583        let wl = cfg.effective_whitelist();
1584        assert!(wl.iter().any(|c| c == "cargo test"));
1585        assert!(
1586            !wl.iter().any(|c| c.starts_with("git push")),
1587            "conservative drops git push"
1588        );
1589        assert!(
1590            !wl.iter().any(|c| c.starts_with("curl")),
1591            "conservative drops curl"
1592        );
1593    }
1594
1595    #[test]
1596    fn auto_approve_extras_are_unioned_with_defaults() {
1597        let cfg = AutoApproveConfig {
1598            safe_commands: vec!["just lint".to_string(), "just test".to_string()],
1599            ..AutoApproveConfig::default()
1600        };
1601        let wl = cfg.effective_whitelist();
1602        assert!(wl.iter().any(|c| c == "cargo fmt"));
1603        assert!(wl.iter().any(|c| c == "just lint"));
1604        assert!(wl.iter().any(|c| c == "just test"));
1605    }
1606
1607    #[test]
1608    fn auto_approve_empty_extras_keep_defaults() {
1609        let cfg = AutoApproveConfig::default();
1610        let wl = cfg.effective_whitelist();
1611        assert!(wl.iter().any(|c| c == "cargo test"));
1612    }
1613
1614    /// Spec scenario `auto-approve-patterns/safe-command-classification`:
1615    /// "Config adds project-specific patterns" — a TOML config with
1616    /// `safe_commands = ["just smoke"]` must yield an effective whitelist
1617    /// such that `is_safe_command("just smoke -v", &whitelist)` is true.
1618    /// "Config does not weaken defaults" — `safe_commands = []` must keep
1619    /// the built-in defaults available to `is_safe_command`.
1620    #[test]
1621    fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
1622        use crate::supervisor::auto_approve::is_safe_command;
1623
1624        // (1) Extras case: a project-specific entry parsed from TOML must
1625        //     classify a command using that prefix as safe.
1626        let tmp = TempDir::new().unwrap();
1627        let extras_path = tmp.path().join("extras.toml");
1628        write_file(
1629            &extras_path,
1630            "[supervisor]\n\
1631             enabled = true\n\
1632             [supervisor.auto_approve]\n\
1633             safe_commands = [\"just smoke\"]\n",
1634        );
1635        let extras_config = load_config_file(&extras_path).unwrap().unwrap();
1636        let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
1637        let extras_whitelist = extras_aa.effective_whitelist();
1638        assert!(
1639            is_safe_command("just smoke -v", &extras_whitelist),
1640            "TOML extra `just smoke` must accept `just smoke -v`"
1641        );
1642        // The defaults must still be present alongside the extra.
1643        assert!(
1644            is_safe_command("cargo test", &extras_whitelist),
1645            "extras must not displace built-in defaults"
1646        );
1647
1648        // (2) Empty extras: the effective whitelist must still classify the
1649        //     built-in defaults (e.g. `cargo test`) as safe.
1650        let empty_path = tmp.path().join("empty.toml");
1651        write_file(
1652            &empty_path,
1653            "[supervisor]\n\
1654             enabled = true\n\
1655             [supervisor.auto_approve]\n\
1656             safe_commands = []\n",
1657        );
1658        let empty_config = load_config_file(&empty_path).unwrap().unwrap();
1659        let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
1660        let empty_whitelist = empty_aa.effective_whitelist();
1661        assert!(
1662            is_safe_command("cargo test", &empty_whitelist),
1663            "empty safe_commands must keep built-in defaults"
1664        );
1665        assert!(
1666            is_safe_command("cargo fmt --check", &empty_whitelist),
1667            "empty safe_commands must keep `cargo fmt` default"
1668        );
1669        // A command outside the defaults must still be rejected.
1670        assert!(
1671            !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
1672            "empty safe_commands must not whitelist arbitrary commands"
1673        );
1674    }
1675
1676    #[test]
1677    fn v030_config_loads_without_auto_approve() {
1678        // Backward-compat: an existing v0.3.0 config that has neither
1679        // [supervisor] nor [supervisor.auto_approve] must parse cleanly.
1680        let tmp = TempDir::new().unwrap();
1681        let path = tmp.path().join("config.toml");
1682        write_file(
1683            &path,
1684            "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
1685        );
1686        let config = load_config_file(&path).unwrap().unwrap();
1687        assert!(config.supervisor.is_none());
1688        assert!(config.broker.enabled);
1689    }
1690}