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