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/// HTTP broker configuration for agent coordination.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct BrokerConfig {
56    /// Whether the broker is enabled.
57    #[serde(default)]
58    pub enabled: bool,
59    /// TCP port the broker listens on.
60    #[serde(default = "BrokerConfig::default_port")]
61    pub port: u16,
62    /// Bind address for the broker.
63    #[serde(default = "BrokerConfig::default_bind")]
64    pub bind: String,
65}
66
67impl Default for BrokerConfig {
68    fn default() -> Self {
69        Self {
70            enabled: false,
71            port: 9119,
72            bind: "127.0.0.1".to_string(),
73        }
74    }
75}
76
77impl BrokerConfig {
78    /// Returns the full URL for the broker endpoint.
79    pub fn url(&self) -> String {
80        format!("http://{}:{}", self.bind, self.port)
81    }
82
83    fn default_port() -> u16 {
84        9119
85    }
86
87    fn default_bind() -> String {
88        "127.0.0.1".to_string()
89    }
90}
91
92/// Top-level git-paw configuration.
93///
94/// All fields are optional — absent config files produce empty defaults.
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct PawConfig {
97    /// Default CLI to use when none is specified.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub default_cli: Option<String>,
100
101    /// Default CLI for `--from-specs` (bypasses picker when set).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub default_spec_cli: Option<String>,
104
105    /// Prefix for spec-derived branch names (default: `"spec/"`).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub branch_prefix: Option<String>,
108
109    /// Whether to enable tmux mouse mode for sessions.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub mouse: Option<bool>,
112
113    /// Custom CLI definitions keyed by name.
114    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
115    pub clis: HashMap<String, CustomCli>,
116
117    /// Named presets keyed by name.
118    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
119    pub presets: HashMap<String, Preset>,
120
121    /// Spec scanning configuration.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub specs: Option<SpecsConfig>,
124
125    /// Session logging configuration.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub logging: Option<LoggingConfig>,
128
129    /// HTTP broker configuration.
130    #[serde(default)]
131    pub broker: BrokerConfig,
132}
133
134impl PawConfig {
135    /// Returns a new config that merges `overlay` on top of `self`.
136    ///
137    /// Scalar fields from `overlay` take precedence when present.
138    /// Map fields are merged with `overlay` entries winning on key collisions.
139    #[must_use]
140    pub fn merged_with(&self, overlay: &Self) -> Self {
141        let mut clis = self.clis.clone();
142        for (k, v) in &overlay.clis {
143            clis.insert(k.clone(), v.clone());
144        }
145
146        let mut presets = self.presets.clone();
147        for (k, v) in &overlay.presets {
148            presets.insert(k.clone(), v.clone());
149        }
150
151        Self {
152            default_cli: overlay
153                .default_cli
154                .clone()
155                .or_else(|| self.default_cli.clone()),
156            default_spec_cli: overlay
157                .default_spec_cli
158                .clone()
159                .or_else(|| self.default_spec_cli.clone()),
160            branch_prefix: overlay
161                .branch_prefix
162                .clone()
163                .or_else(|| self.branch_prefix.clone()),
164            mouse: overlay.mouse.or(self.mouse),
165            clis,
166            presets,
167            specs: overlay.specs.clone().or_else(|| self.specs.clone()),
168            logging: overlay.logging.clone().or_else(|| self.logging.clone()),
169            broker: if overlay.broker == BrokerConfig::default() {
170                self.broker.clone()
171            } else {
172                overlay.broker.clone()
173            },
174        }
175    }
176
177    /// Returns a preset by name, if it exists.
178    pub fn get_preset(&self, name: &str) -> Option<&Preset> {
179        self.presets.get(name)
180    }
181}
182
183/// Returns the path to the global config file (`~/.config/git-paw/config.toml`).
184pub fn global_config_path() -> Result<PathBuf, PawError> {
185    crate::dirs::config_dir()
186        .map(|d| d.join("git-paw").join("config.toml"))
187        .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
188}
189
190/// Returns the path to a repo-level config file (`.git-paw/config.toml`).
191pub fn repo_config_path(repo_root: &Path) -> PathBuf {
192    repo_root.join(".git-paw").join("config.toml")
193}
194
195/// Loads a [`PawConfig`] from a TOML file, returning `Ok(None)` if the file does not exist.
196fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
197    match fs::read_to_string(path) {
198        Ok(contents) => {
199            let config: PawConfig = toml::from_str(&contents)
200                .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
201            Ok(Some(config))
202        }
203        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
204        Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
205    }
206}
207
208/// Loads only the repo-level configuration (`.git-paw/config.toml`).
209///
210/// Returns defaults if the file does not exist. Useful when you need to
211/// update and save repo-level settings without clobbering global values.
212pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
213    Ok(load_config_file(&repo_config_path(repo_root))?.unwrap_or_default())
214}
215
216/// Loads the merged configuration for a repository.
217///
218/// Reads the global config and the per-repo config, merging them with
219/// repo settings taking precedence. Returns defaults if neither file exists.
220pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
221    let global_path = global_config_path()?;
222    load_config_from(&global_path, repo_root)
223}
224
225/// Loads merged config from an explicit global path and repo root.
226pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
227    let global = load_config_file(global_path)?.unwrap_or_default();
228    let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
229    Ok(global.merged_with(&repo))
230}
231
232/// Saves a [`PawConfig`] to the repo-level config file (`.git-paw/config.toml`).
233pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
234    save_config_to(&repo_config_path(repo_root), config)
235}
236
237/// Writes a [`PawConfig`] to a TOML file atomically (temp file + rename).
238fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
239    let dir = path
240        .parent()
241        .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
242    fs::create_dir_all(dir)
243        .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
244
245    let contents =
246        toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
247
248    // Atomic write: temp file + rename
249    let tmp = path.with_extension("toml.tmp");
250    fs::write(&tmp, &contents)
251        .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
252    fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
253
254    Ok(())
255}
256
257/// Adds a custom CLI to the global config.
258///
259/// If `command` is not an absolute path, it is resolved via PATH using `which`.
260pub fn add_custom_cli(
261    name: &str,
262    command: &str,
263    display_name: Option<&str>,
264) -> Result<(), PawError> {
265    add_custom_cli_to(&global_config_path()?, name, command, display_name)
266}
267
268/// Adds a custom CLI to the config at the given path.
269///
270/// If `command` is not an absolute path, it is resolved via PATH using `which`.
271pub fn add_custom_cli_to(
272    config_path: &Path,
273    name: &str,
274    command: &str,
275    display_name: Option<&str>,
276) -> Result<(), PawError> {
277    let resolved_command = if Path::new(command).is_absolute() {
278        command.to_string()
279    } else {
280        which::which(command)
281            .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
282            .to_string_lossy()
283            .into_owned()
284    };
285
286    let mut config = load_config_file(config_path)?.unwrap_or_default();
287
288    config.clis.insert(
289        name.to_string(),
290        CustomCli {
291            command: resolved_command,
292            display_name: display_name.map(String::from),
293        },
294    );
295
296    save_config_to(config_path, &config)
297}
298
299/// Returns a default `config.toml` string with sensible defaults and
300/// commented-out v0.2.0 fields for discoverability.
301pub fn generate_default_config() -> String {
302    r#"# git-paw configuration
303# See https://github.com/bearicorn/git-paw for documentation.
304
305# Pre-select a CLI in the interactive picker (user can still change).
306# Omit to show the full picker with no default.
307# default_cli = ""
308
309# Enable tmux mouse mode for sessions (default: true).
310# mouse = true
311
312# Bypass the CLI picker entirely for --from-specs mode.
313# Omit to prompt or use per-spec paw_cli fields.
314# default_spec_cli = ""
315
316# Prefix for spec-derived branch names (default: "spec/").
317# branch_prefix = "spec/"
318
319# Spec scanning configuration.
320# [specs]
321# dir = "specs"
322#
323# OpenSpec format (directory-based, default):
324# type = "openspec"
325#
326# Markdown format (frontmatter-based):
327# type = "markdown"
328# Each .md file uses YAML frontmatter fields:
329#   paw_status  — "pending" | "done" | "in-progress" (required)
330#   paw_branch  — branch name suffix (optional, falls back to filename)
331#   paw_cli     — CLI override for this spec (optional)
332
333# Session logging configuration.
334# [logging]
335# enabled = false
336
337# HTTP broker for agent coordination (requires --broker flag on start).
338# [broker]
339# enabled = true
340# port = 9119
341# bind = "127.0.0.1"
342
343# Custom CLI definitions.
344# [clis.my-agent]
345# command = "/usr/local/bin/my-agent"
346# display_name = "My Agent"
347
348# Named presets for quick launches.
349# [presets.my-preset]
350# branches = ["feat/api", "fix/db"]
351# cli = ""
352"#
353    .to_string()
354}
355
356/// Removes a custom CLI from the global config.
357///
358/// Returns `PawError::CliNotFound` if the name is not present in the config.
359pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
360    remove_custom_cli_from(&global_config_path()?, name)
361}
362
363/// Removes a custom CLI from the config at the given path.
364///
365/// Returns `PawError::CliNotFound` if the name is not present in the config.
366pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
367    let mut config = load_config_file(config_path)?.unwrap_or_default();
368
369    if config.clis.remove(name).is_none() {
370        return Err(PawError::CliNotFound(name.to_string()));
371    }
372
373    save_config_to(config_path, &config)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use tempfile::TempDir;
380
381    fn write_file(path: &Path, content: &str) {
382        if let Some(parent) = path.parent() {
383            fs::create_dir_all(parent).unwrap();
384        }
385        fs::write(path, content).unwrap();
386    }
387
388    // --- Parsing behavior ---
389
390    #[test]
391    fn parses_config_with_all_fields() {
392        let tmp = TempDir::new().unwrap();
393        let path = tmp.path().join("config.toml");
394        write_file(
395            &path,
396            r#"
397default_cli = "claude"
398mouse = false
399default_spec_cli = "gemini"
400branch_prefix = "spec/"
401
402[clis.my-agent]
403command = "/usr/local/bin/my-agent"
404display_name = "My Agent"
405
406[clis.local-llm]
407command = "ollama-code"
408
409[presets.backend]
410branches = ["feature/api", "fix/db"]
411cli = "claude"
412
413[specs]
414dir = "my-specs"
415type = "openspec"
416
417[logging]
418enabled = true
419"#,
420        );
421
422        let config = load_config_file(&path).unwrap().unwrap();
423        assert_eq!(config.default_cli.as_deref(), Some("claude"));
424        assert_eq!(config.mouse, Some(false));
425        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
426        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
427        assert_eq!(config.clis.len(), 2);
428        assert_eq!(
429            config.clis["my-agent"].display_name.as_deref(),
430            Some("My Agent")
431        );
432        assert_eq!(config.clis["local-llm"].command, "ollama-code");
433        assert_eq!(config.presets["backend"].cli, "claude");
434        assert_eq!(
435            config.presets["backend"].branches,
436            vec!["feature/api", "fix/db"]
437        );
438        let specs = config.specs.unwrap();
439        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
440        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
441        let logging = config.logging.unwrap();
442        assert!(logging.enabled);
443    }
444
445    #[test]
446    fn all_fields_are_optional() {
447        let tmp = TempDir::new().unwrap();
448        let path = tmp.path().join("config.toml");
449        write_file(&path, "default_cli = \"gemini\"\n");
450
451        let config = load_config_file(&path).unwrap().unwrap();
452        assert_eq!(config.default_cli.as_deref(), Some("gemini"));
453        assert_eq!(config.mouse, None);
454        assert!(config.clis.is_empty());
455        assert!(config.presets.is_empty());
456    }
457
458    #[test]
459    fn returns_defaults_when_no_files_exist() {
460        let tmp = TempDir::new().unwrap();
461        let global_path = tmp.path().join("nonexistent").join("config.toml");
462        let repo_root = tmp.path().join("repo");
463        fs::create_dir_all(&repo_root).unwrap();
464
465        let config = load_config_from(&global_path, &repo_root).unwrap();
466        assert_eq!(config.default_cli, None);
467        assert_eq!(config.mouse, None);
468        assert!(config.clis.is_empty());
469        assert!(config.presets.is_empty());
470    }
471
472    #[test]
473    fn reports_error_for_invalid_toml() {
474        let tmp = TempDir::new().unwrap();
475        let path = tmp.path().join("bad.toml");
476        write_file(&path, "this is not [valid toml");
477
478        let err = load_config_file(&path).unwrap_err();
479        assert!(err.to_string().contains("bad.toml"));
480    }
481
482    // --- Merge behavior (through file I/O) ---
483
484    #[test]
485    fn repo_config_overrides_global_scalars() {
486        let tmp = TempDir::new().unwrap();
487        let global_path = tmp.path().join("global").join("config.toml");
488        let repo_root = tmp.path().join("repo");
489        fs::create_dir_all(&repo_root).unwrap();
490
491        write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
492        write_file(
493            &repo_config_path(&repo_root),
494            "default_cli = \"gemini\"\n", // mouse intentionally absent
495        );
496
497        let config = load_config_from(&global_path, &repo_root).unwrap();
498        assert_eq!(config.default_cli.as_deref(), Some("gemini")); // repo wins
499        assert_eq!(config.mouse, Some(true)); // global preserved when repo absent
500    }
501
502    #[test]
503    fn repo_config_merges_cli_maps() {
504        let tmp = TempDir::new().unwrap();
505        let global_path = tmp.path().join("global").join("config.toml");
506        let repo_root = tmp.path().join("repo");
507        fs::create_dir_all(&repo_root).unwrap();
508
509        write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
510        write_file(
511            &repo_config_path(&repo_root),
512            "[clis.agent-b]\ncommand = \"/bin/b\"\n",
513        );
514
515        let config = load_config_from(&global_path, &repo_root).unwrap();
516        assert_eq!(config.clis.len(), 2);
517        assert!(config.clis.contains_key("agent-a"));
518        assert!(config.clis.contains_key("agent-b"));
519    }
520
521    #[test]
522    fn repo_cli_overrides_global_cli_with_same_name() {
523        let tmp = TempDir::new().unwrap();
524        let global_path = tmp.path().join("global").join("config.toml");
525        let repo_root = tmp.path().join("repo");
526        fs::create_dir_all(&repo_root).unwrap();
527
528        write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
529        write_file(
530            &repo_config_path(&repo_root),
531            "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
532        );
533
534        let config = load_config_from(&global_path, &repo_root).unwrap();
535        assert_eq!(config.clis["my-agent"].command, "/new/path");
536        assert_eq!(
537            config.clis["my-agent"].display_name.as_deref(),
538            Some("Overridden")
539        );
540    }
541
542    #[test]
543    fn load_config_from_reads_global_file_when_no_repo() {
544        let tmp = TempDir::new().unwrap();
545        let global_path = tmp.path().join("global").join("config.toml");
546        let repo_root = tmp.path().join("repo");
547        fs::create_dir_all(&repo_root).unwrap();
548
549        write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
550        // No .git-paw/config.toml in repo_root
551
552        let config = load_config_from(&global_path, &repo_root).unwrap();
553        assert_eq!(config.default_cli.as_deref(), Some("claude"));
554        assert_eq!(config.mouse, Some(false));
555    }
556
557    #[test]
558    fn load_config_from_reads_repo_file_when_no_global() {
559        let tmp = TempDir::new().unwrap();
560        let global_path = tmp.path().join("nonexistent").join("config.toml");
561        let repo_root = tmp.path().join("repo");
562        fs::create_dir_all(&repo_root).unwrap();
563
564        write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
565
566        let config = load_config_from(&global_path, &repo_root).unwrap();
567        assert_eq!(config.default_cli.as_deref(), Some("codex"));
568    }
569
570    // --- Preset behavior ---
571
572    #[test]
573    fn preset_accessible_by_name() {
574        let tmp = TempDir::new().unwrap();
575        let global_path = tmp.path().join("global").join("config.toml");
576        let repo_root = tmp.path().join("repo");
577        fs::create_dir_all(&repo_root).unwrap();
578
579        write_file(
580            &repo_config_path(&repo_root),
581            "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
582        );
583
584        let config = load_config_from(&global_path, &repo_root).unwrap();
585        let preset = config.get_preset("backend").unwrap();
586        assert_eq!(preset.cli, "claude");
587        assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
588    }
589
590    #[test]
591    fn preset_returns_none_when_not_in_config() {
592        let tmp = TempDir::new().unwrap();
593        let global_path = tmp.path().join("config.toml");
594        write_file(&global_path, "default_cli = \"claude\"\n");
595
596        let config = load_config_file(&global_path).unwrap().unwrap();
597        assert!(config.get_preset("nonexistent").is_none());
598    }
599
600    // --- add_custom_cli behavior ---
601
602    #[test]
603    fn add_cli_writes_to_config_file() {
604        let tmp = TempDir::new().unwrap();
605        let config_path = tmp.path().join("git-paw").join("config.toml");
606
607        // Add a CLI with an absolute path (no PATH resolution needed)
608        add_custom_cli_to(
609            &config_path,
610            "my-agent",
611            "/usr/local/bin/my-agent",
612            Some("My Agent"),
613        )
614        .unwrap();
615
616        // Verify by loading the file back
617        let config = load_config_file(&config_path).unwrap().unwrap();
618        assert_eq!(config.clis.len(), 1);
619        assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
620        assert_eq!(
621            config.clis["my-agent"].display_name.as_deref(),
622            Some("My Agent")
623        );
624    }
625
626    #[test]
627    fn add_cli_preserves_existing_entries() {
628        let tmp = TempDir::new().unwrap();
629        let config_path = tmp.path().join("git-paw").join("config.toml");
630
631        add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
632        add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
633
634        let config = load_config_file(&config_path).unwrap().unwrap();
635        assert_eq!(config.clis.len(), 2);
636        assert!(config.clis.contains_key("first"));
637        assert!(config.clis.contains_key("second"));
638    }
639
640    #[test]
641    fn add_cli_errors_when_command_not_on_path() {
642        let tmp = TempDir::new().unwrap();
643        let config_path = tmp.path().join("config.toml");
644
645        let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
646            .unwrap_err();
647        assert!(err.to_string().contains("not found on PATH"));
648    }
649
650    // --- remove_custom_cli behavior ---
651
652    #[test]
653    fn remove_cli_deletes_entry_from_config_file() {
654        let tmp = TempDir::new().unwrap();
655        let config_path = tmp.path().join("git-paw").join("config.toml");
656
657        // Set up: add two CLIs
658        add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
659        add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
660
661        // Act: remove one
662        remove_custom_cli_from(&config_path, "remove-me").unwrap();
663
664        // Verify: only the kept CLI remains
665        let config = load_config_file(&config_path).unwrap().unwrap();
666        assert_eq!(config.clis.len(), 1);
667        assert!(config.clis.contains_key("keep-me"));
668        assert!(!config.clis.contains_key("remove-me"));
669    }
670
671    #[test]
672    fn remove_nonexistent_cli_returns_cli_not_found_error() {
673        let tmp = TempDir::new().unwrap();
674        let config_path = tmp.path().join("config.toml");
675        // Empty config file
676        write_file(&config_path, "");
677
678        let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
679        match err {
680            PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
681            other => panic!("expected CliNotFound, got: {other}"),
682        }
683    }
684
685    #[test]
686    fn remove_cli_from_empty_config_returns_error() {
687        let tmp = TempDir::new().unwrap();
688        let config_path = tmp.path().join("config.toml");
689        // No file at all
690
691        let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
692        match err {
693            PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
694            other => panic!("expected CliNotFound, got: {other}"),
695        }
696    }
697
698    // --- Round-trip: config survives write + read ---
699
700    // --- default_spec_cli behavior ---
701
702    #[test]
703    fn parses_default_spec_cli_when_present() {
704        let tmp = TempDir::new().unwrap();
705        let path = tmp.path().join("config.toml");
706        write_file(&path, "default_spec_cli = \"claude\"\n");
707
708        let config = load_config_file(&path).unwrap().unwrap();
709        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
710    }
711
712    #[test]
713    fn default_spec_cli_defaults_to_none() {
714        let tmp = TempDir::new().unwrap();
715        let path = tmp.path().join("config.toml");
716        write_file(&path, "default_cli = \"claude\"\n");
717
718        let config = load_config_file(&path).unwrap().unwrap();
719        assert_eq!(config.default_spec_cli, None);
720    }
721
722    #[test]
723    fn repo_overrides_global_default_spec_cli() {
724        let tmp = TempDir::new().unwrap();
725        let global_path = tmp.path().join("global").join("config.toml");
726        let repo_root = tmp.path().join("repo");
727        fs::create_dir_all(&repo_root).unwrap();
728
729        write_file(&global_path, "default_spec_cli = \"claude\"\n");
730        write_file(
731            &repo_config_path(&repo_root),
732            "default_spec_cli = \"gemini\"\n",
733        );
734
735        let config = load_config_from(&global_path, &repo_root).unwrap();
736        assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
737    }
738
739    #[test]
740    fn global_default_spec_cli_preserved_when_repo_absent() {
741        let tmp = TempDir::new().unwrap();
742        let global_path = tmp.path().join("global").join("config.toml");
743        let repo_root = tmp.path().join("repo");
744        fs::create_dir_all(&repo_root).unwrap();
745
746        write_file(&global_path, "default_spec_cli = \"claude\"\n");
747
748        let config = load_config_from(&global_path, &repo_root).unwrap();
749        assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
750    }
751
752    // --- Round-trip: config survives write + read ---
753
754    #[test]
755    fn config_survives_save_and_load() {
756        let tmp = TempDir::new().unwrap();
757        let config_path = tmp.path().join("config.toml");
758
759        let original = PawConfig {
760            default_cli: Some("claude".into()),
761            default_spec_cli: None,
762            branch_prefix: None,
763            mouse: Some(true),
764            clis: HashMap::from([(
765                "test".into(),
766                CustomCli {
767                    command: "/bin/test".into(),
768                    display_name: Some("Test CLI".into()),
769                },
770            )]),
771            presets: HashMap::from([(
772                "dev".into(),
773                Preset {
774                    branches: vec!["main".into()],
775                    cli: "claude".into(),
776                },
777            )]),
778            specs: None,
779            logging: None,
780            broker: BrokerConfig::default(),
781        };
782
783        save_config_to(&config_path, &original).unwrap();
784        let loaded = load_config_file(&config_path).unwrap().unwrap();
785        assert_eq!(original, loaded);
786    }
787
788    // --- Gap #1: Parse [specs] section with populated fields ---
789
790    #[test]
791    fn parses_specs_section_with_populated_fields() {
792        let tmp = TempDir::new().unwrap();
793        let path = tmp.path().join("config.toml");
794        write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
795
796        let config = load_config_file(&path).unwrap().unwrap();
797        let specs = config.specs.unwrap();
798        assert_eq!(specs.dir.as_deref(), Some("my-specs"));
799        assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
800    }
801
802    // --- Gap #2: Parse [logging] section with enabled ---
803
804    #[test]
805    fn parses_logging_section_with_enabled() {
806        let tmp = TempDir::new().unwrap();
807        let path = tmp.path().join("config.toml");
808        write_file(&path, "[logging]\nenabled = true\n");
809
810        let config = load_config_file(&path).unwrap().unwrap();
811        let logging = config.logging.unwrap();
812        assert!(logging.enabled);
813    }
814
815    // --- Gap #3: Round-trip with specs and logging populated ---
816
817    #[test]
818    fn round_trip_with_specs_and_logging() {
819        let tmp = TempDir::new().unwrap();
820        let config_path = tmp.path().join("config.toml");
821
822        let original = PawConfig {
823            specs: Some(SpecsConfig {
824                dir: Some("specs".into()),
825                spec_type: Some("openspec".into()),
826            }),
827            logging: Some(LoggingConfig { enabled: true }),
828            ..Default::default()
829        };
830
831        save_config_to(&config_path, &original).unwrap();
832        let loaded = load_config_file(&config_path).unwrap().unwrap();
833        assert_eq!(original, loaded);
834        assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
835        assert!(loaded.logging.unwrap().enabled);
836    }
837
838    // --- Gap #4: Generated config is valid TOML ---
839
840    #[test]
841    fn generated_default_config_is_valid_toml() {
842        let raw = generate_default_config();
843        let stripped: String = raw
844            .lines()
845            .filter(|line| !line.trim_start().starts_with('#'))
846            .collect::<Vec<&str>>()
847            .join("\n");
848
849        let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
850        assert!(
851            parsed.is_ok(),
852            "generated config with comments stripped should be valid TOML, got: {:?}",
853            parsed.unwrap_err()
854        );
855    }
856
857    // --- Gap #5: branch_prefix merge ---
858
859    #[test]
860    fn branch_prefix_repo_overrides_global() {
861        let tmp = TempDir::new().unwrap();
862        let global_path = tmp.path().join("global").join("config.toml");
863        let repo_root = tmp.path().join("repo");
864        fs::create_dir_all(&repo_root).unwrap();
865
866        write_file(&global_path, "branch_prefix = \"feat/\"\n");
867        write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
868
869        let config = load_config_from(&global_path, &repo_root).unwrap();
870        assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
871    }
872
873    #[test]
874    fn generated_default_config_contains_commented_examples() {
875        let output = generate_default_config();
876        assert!(
877            output.contains("default_spec_cli"),
878            "should contain default_spec_cli"
879        );
880        assert!(
881            output.contains("branch_prefix"),
882            "should contain branch_prefix"
883        );
884        assert!(output.contains("[specs]"), "should contain [specs]");
885        assert!(output.contains("[logging]"), "should contain [logging]");
886        assert!(output.contains("[broker]"), "should contain [broker]");
887    }
888
889    // --- BrokerConfig ---
890
891    #[test]
892    fn broker_config_defaults() {
893        let config = BrokerConfig::default();
894        assert!(!config.enabled);
895        assert_eq!(config.port, 9119);
896        assert_eq!(config.bind, "127.0.0.1");
897    }
898
899    #[test]
900    fn broker_config_url() {
901        let config = BrokerConfig::default();
902        assert_eq!(config.url(), "http://127.0.0.1:9119");
903
904        let custom = BrokerConfig {
905            enabled: true,
906            port: 8080,
907            bind: "0.0.0.0".to_string(),
908        };
909        assert_eq!(custom.url(), "http://0.0.0.0:8080");
910    }
911
912    #[test]
913    fn empty_config_gets_broker_defaults() {
914        let tmp = TempDir::new().unwrap();
915        let path = tmp.path().join("config.toml");
916        write_file(&path, "");
917
918        let config = load_config_file(&path).unwrap().unwrap();
919        assert!(!config.broker.enabled);
920        assert_eq!(config.broker.port, 9119);
921        assert_eq!(config.broker.bind, "127.0.0.1");
922    }
923
924    #[test]
925    fn parses_full_broker_section() {
926        let tmp = TempDir::new().unwrap();
927        let path = tmp.path().join("config.toml");
928        write_file(
929            &path,
930            "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
931        );
932
933        let config = load_config_file(&path).unwrap().unwrap();
934        assert!(config.broker.enabled);
935        assert_eq!(config.broker.port, 8080);
936        assert_eq!(config.broker.bind, "0.0.0.0");
937    }
938
939    #[test]
940    fn parses_partial_broker_section() {
941        let tmp = TempDir::new().unwrap();
942        let path = tmp.path().join("config.toml");
943        write_file(&path, "[broker]\nenabled = true\n");
944
945        let config = load_config_file(&path).unwrap().unwrap();
946        assert!(config.broker.enabled);
947        assert_eq!(config.broker.port, 9119);
948        assert_eq!(config.broker.bind, "127.0.0.1");
949    }
950
951    #[test]
952    fn broker_config_round_trip() {
953        let tmp = TempDir::new().unwrap();
954        let config_path = tmp.path().join("config.toml");
955
956        let original = PawConfig {
957            broker: BrokerConfig {
958                enabled: true,
959                port: 9200,
960                bind: "127.0.0.1".to_string(),
961            },
962            ..Default::default()
963        };
964
965        save_config_to(&config_path, &original).unwrap();
966        let loaded = load_config_file(&config_path).unwrap().unwrap();
967        assert_eq!(loaded.broker.enabled, original.broker.enabled);
968        assert_eq!(loaded.broker.port, original.broker.port);
969        assert_eq!(loaded.broker.bind, original.broker.bind);
970    }
971}