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/// Top-level git-paw configuration.
35///
36/// All fields are optional — absent config files produce empty defaults.
37#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct PawConfig {
39    /// Default CLI to use when none is specified.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub default_cli: Option<String>,
42
43    /// Whether to enable tmux mouse mode for sessions.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub mouse: Option<bool>,
46
47    /// Custom CLI definitions keyed by name.
48    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49    pub clis: HashMap<String, CustomCli>,
50
51    /// Named presets keyed by name.
52    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53    pub presets: HashMap<String, Preset>,
54}
55
56impl PawConfig {
57    /// Returns a new config that merges `overlay` on top of `self`.
58    ///
59    /// Scalar fields from `overlay` take precedence when present.
60    /// Map fields are merged with `overlay` entries winning on key collisions.
61    #[must_use]
62    pub fn merged_with(&self, overlay: &Self) -> Self {
63        let mut clis = self.clis.clone();
64        for (k, v) in &overlay.clis {
65            clis.insert(k.clone(), v.clone());
66        }
67
68        let mut presets = self.presets.clone();
69        for (k, v) in &overlay.presets {
70            presets.insert(k.clone(), v.clone());
71        }
72
73        Self {
74            default_cli: overlay
75                .default_cli
76                .clone()
77                .or_else(|| self.default_cli.clone()),
78            mouse: overlay.mouse.or(self.mouse),
79            clis,
80            presets,
81        }
82    }
83
84    /// Returns a preset by name, if it exists.
85    pub fn get_preset(&self, name: &str) -> Option<&Preset> {
86        self.presets.get(name)
87    }
88}
89
90/// Returns the path to the global config file (`~/.config/git-paw/config.toml`).
91pub fn global_config_path() -> Result<PathBuf, PawError> {
92    crate::dirs::config_dir()
93        .map(|d| d.join("git-paw").join("config.toml"))
94        .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
95}
96
97/// Returns the path to a repo-level config file (`.git-paw/config.toml`).
98pub fn repo_config_path(repo_root: &Path) -> PathBuf {
99    repo_root.join(".git-paw").join("config.toml")
100}
101
102/// Loads a [`PawConfig`] from a TOML file, returning `Ok(None)` if the file does not exist.
103fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
104    match fs::read_to_string(path) {
105        Ok(contents) => {
106            let config: PawConfig = toml::from_str(&contents)
107                .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
108            Ok(Some(config))
109        }
110        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
111        Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
112    }
113}
114
115/// Loads the merged configuration for a repository.
116///
117/// Reads the global config and the per-repo config, merging them with
118/// repo settings taking precedence. Returns defaults if neither file exists.
119pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
120    let global_path = global_config_path()?;
121    load_config_from(&global_path, repo_root)
122}
123
124/// Loads merged config from an explicit global path and repo root.
125pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
126    let global = load_config_file(global_path)?.unwrap_or_default();
127    let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
128    Ok(global.merged_with(&repo))
129}
130
131/// Writes a [`PawConfig`] to a TOML file atomically (temp file + rename).
132fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
133    let dir = path
134        .parent()
135        .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
136    fs::create_dir_all(dir)
137        .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
138
139    let contents =
140        toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
141
142    // Atomic write: temp file + rename
143    let tmp = path.with_extension("toml.tmp");
144    fs::write(&tmp, &contents)
145        .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
146    fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
147
148    Ok(())
149}
150
151/// Adds a custom CLI to the global config.
152///
153/// If `command` is not an absolute path, it is resolved via PATH using `which`.
154pub fn add_custom_cli(
155    name: &str,
156    command: &str,
157    display_name: Option<&str>,
158) -> Result<(), PawError> {
159    add_custom_cli_to(&global_config_path()?, name, command, display_name)
160}
161
162/// Adds a custom CLI to the config at the given path.
163///
164/// If `command` is not an absolute path, it is resolved via PATH using `which`.
165pub fn add_custom_cli_to(
166    config_path: &Path,
167    name: &str,
168    command: &str,
169    display_name: Option<&str>,
170) -> Result<(), PawError> {
171    let resolved_command = if Path::new(command).is_absolute() {
172        command.to_string()
173    } else {
174        which::which(command)
175            .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
176            .to_string_lossy()
177            .into_owned()
178    };
179
180    let mut config = load_config_file(config_path)?.unwrap_or_default();
181
182    config.clis.insert(
183        name.to_string(),
184        CustomCli {
185            command: resolved_command,
186            display_name: display_name.map(String::from),
187        },
188    );
189
190    save_config_to(config_path, &config)
191}
192
193/// Removes a custom CLI from the global config.
194///
195/// Returns `PawError::CliNotFound` if the name is not present in the config.
196pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
197    remove_custom_cli_from(&global_config_path()?, name)
198}
199
200/// Removes a custom CLI from the config at the given path.
201///
202/// Returns `PawError::CliNotFound` if the name is not present in the config.
203pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
204    let mut config = load_config_file(config_path)?.unwrap_or_default();
205
206    if config.clis.remove(name).is_none() {
207        return Err(PawError::CliNotFound(name.to_string()));
208    }
209
210    save_config_to(config_path, &config)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use tempfile::TempDir;
217
218    fn write_file(path: &Path, content: &str) {
219        if let Some(parent) = path.parent() {
220            fs::create_dir_all(parent).unwrap();
221        }
222        fs::write(path, content).unwrap();
223    }
224
225    // --- Parsing behavior ---
226
227    #[test]
228    fn parses_config_with_all_fields() {
229        let tmp = TempDir::new().unwrap();
230        let path = tmp.path().join("config.toml");
231        write_file(
232            &path,
233            r#"
234default_cli = "claude"
235mouse = false
236
237[clis.my-agent]
238command = "/usr/local/bin/my-agent"
239display_name = "My Agent"
240
241[clis.local-llm]
242command = "ollama-code"
243
244[presets.backend]
245branches = ["feature/api", "fix/db"]
246cli = "claude"
247"#,
248        );
249
250        let config = load_config_file(&path).unwrap().unwrap();
251        assert_eq!(config.default_cli.as_deref(), Some("claude"));
252        assert_eq!(config.mouse, Some(false));
253        assert_eq!(config.clis.len(), 2);
254        assert_eq!(
255            config.clis["my-agent"].display_name.as_deref(),
256            Some("My Agent")
257        );
258        assert_eq!(config.clis["local-llm"].command, "ollama-code");
259        assert_eq!(config.presets["backend"].cli, "claude");
260        assert_eq!(
261            config.presets["backend"].branches,
262            vec!["feature/api", "fix/db"]
263        );
264    }
265
266    #[test]
267    fn all_fields_are_optional() {
268        let tmp = TempDir::new().unwrap();
269        let path = tmp.path().join("config.toml");
270        write_file(&path, "default_cli = \"gemini\"\n");
271
272        let config = load_config_file(&path).unwrap().unwrap();
273        assert_eq!(config.default_cli.as_deref(), Some("gemini"));
274        assert_eq!(config.mouse, None);
275        assert!(config.clis.is_empty());
276        assert!(config.presets.is_empty());
277    }
278
279    #[test]
280    fn returns_defaults_when_no_files_exist() {
281        let tmp = TempDir::new().unwrap();
282        let global_path = tmp.path().join("nonexistent").join("config.toml");
283        let repo_root = tmp.path().join("repo");
284        fs::create_dir_all(&repo_root).unwrap();
285
286        let config = load_config_from(&global_path, &repo_root).unwrap();
287        assert_eq!(config.default_cli, None);
288        assert_eq!(config.mouse, None);
289        assert!(config.clis.is_empty());
290        assert!(config.presets.is_empty());
291    }
292
293    #[test]
294    fn reports_error_for_invalid_toml() {
295        let tmp = TempDir::new().unwrap();
296        let path = tmp.path().join("bad.toml");
297        write_file(&path, "this is not [valid toml");
298
299        let err = load_config_file(&path).unwrap_err();
300        assert!(err.to_string().contains("bad.toml"));
301    }
302
303    // --- Merge behavior (through file I/O) ---
304
305    #[test]
306    fn repo_config_overrides_global_scalars() {
307        let tmp = TempDir::new().unwrap();
308        let global_path = tmp.path().join("global").join("config.toml");
309        let repo_root = tmp.path().join("repo");
310        fs::create_dir_all(&repo_root).unwrap();
311
312        write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
313        write_file(
314            &repo_config_path(&repo_root),
315            "default_cli = \"gemini\"\n", // mouse intentionally absent
316        );
317
318        let config = load_config_from(&global_path, &repo_root).unwrap();
319        assert_eq!(config.default_cli.as_deref(), Some("gemini")); // repo wins
320        assert_eq!(config.mouse, Some(true)); // global preserved when repo absent
321    }
322
323    #[test]
324    fn repo_config_merges_cli_maps() {
325        let tmp = TempDir::new().unwrap();
326        let global_path = tmp.path().join("global").join("config.toml");
327        let repo_root = tmp.path().join("repo");
328        fs::create_dir_all(&repo_root).unwrap();
329
330        write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
331        write_file(
332            &repo_config_path(&repo_root),
333            "[clis.agent-b]\ncommand = \"/bin/b\"\n",
334        );
335
336        let config = load_config_from(&global_path, &repo_root).unwrap();
337        assert_eq!(config.clis.len(), 2);
338        assert!(config.clis.contains_key("agent-a"));
339        assert!(config.clis.contains_key("agent-b"));
340    }
341
342    #[test]
343    fn repo_cli_overrides_global_cli_with_same_name() {
344        let tmp = TempDir::new().unwrap();
345        let global_path = tmp.path().join("global").join("config.toml");
346        let repo_root = tmp.path().join("repo");
347        fs::create_dir_all(&repo_root).unwrap();
348
349        write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
350        write_file(
351            &repo_config_path(&repo_root),
352            "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
353        );
354
355        let config = load_config_from(&global_path, &repo_root).unwrap();
356        assert_eq!(config.clis["my-agent"].command, "/new/path");
357        assert_eq!(
358            config.clis["my-agent"].display_name.as_deref(),
359            Some("Overridden")
360        );
361    }
362
363    #[test]
364    fn load_config_from_reads_global_file_when_no_repo() {
365        let tmp = TempDir::new().unwrap();
366        let global_path = tmp.path().join("global").join("config.toml");
367        let repo_root = tmp.path().join("repo");
368        fs::create_dir_all(&repo_root).unwrap();
369
370        write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
371        // No .git-paw/config.toml in repo_root
372
373        let config = load_config_from(&global_path, &repo_root).unwrap();
374        assert_eq!(config.default_cli.as_deref(), Some("claude"));
375        assert_eq!(config.mouse, Some(false));
376    }
377
378    #[test]
379    fn load_config_from_reads_repo_file_when_no_global() {
380        let tmp = TempDir::new().unwrap();
381        let global_path = tmp.path().join("nonexistent").join("config.toml");
382        let repo_root = tmp.path().join("repo");
383        fs::create_dir_all(&repo_root).unwrap();
384
385        write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
386
387        let config = load_config_from(&global_path, &repo_root).unwrap();
388        assert_eq!(config.default_cli.as_deref(), Some("codex"));
389    }
390
391    // --- Preset behavior ---
392
393    #[test]
394    fn preset_accessible_by_name() {
395        let tmp = TempDir::new().unwrap();
396        let global_path = tmp.path().join("global").join("config.toml");
397        let repo_root = tmp.path().join("repo");
398        fs::create_dir_all(&repo_root).unwrap();
399
400        write_file(
401            &repo_config_path(&repo_root),
402            "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
403        );
404
405        let config = load_config_from(&global_path, &repo_root).unwrap();
406        let preset = config.get_preset("backend").unwrap();
407        assert_eq!(preset.cli, "claude");
408        assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
409    }
410
411    #[test]
412    fn preset_returns_none_when_not_in_config() {
413        let tmp = TempDir::new().unwrap();
414        let global_path = tmp.path().join("config.toml");
415        write_file(&global_path, "default_cli = \"claude\"\n");
416
417        let config = load_config_file(&global_path).unwrap().unwrap();
418        assert!(config.get_preset("nonexistent").is_none());
419    }
420
421    // --- add_custom_cli behavior ---
422
423    #[test]
424    fn add_cli_writes_to_config_file() {
425        let tmp = TempDir::new().unwrap();
426        let config_path = tmp.path().join("git-paw").join("config.toml");
427
428        // Add a CLI with an absolute path (no PATH resolution needed)
429        add_custom_cli_to(
430            &config_path,
431            "my-agent",
432            "/usr/local/bin/my-agent",
433            Some("My Agent"),
434        )
435        .unwrap();
436
437        // Verify by loading the file back
438        let config = load_config_file(&config_path).unwrap().unwrap();
439        assert_eq!(config.clis.len(), 1);
440        assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
441        assert_eq!(
442            config.clis["my-agent"].display_name.as_deref(),
443            Some("My Agent")
444        );
445    }
446
447    #[test]
448    fn add_cli_preserves_existing_entries() {
449        let tmp = TempDir::new().unwrap();
450        let config_path = tmp.path().join("git-paw").join("config.toml");
451
452        add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
453        add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
454
455        let config = load_config_file(&config_path).unwrap().unwrap();
456        assert_eq!(config.clis.len(), 2);
457        assert!(config.clis.contains_key("first"));
458        assert!(config.clis.contains_key("second"));
459    }
460
461    #[test]
462    fn add_cli_errors_when_command_not_on_path() {
463        let tmp = TempDir::new().unwrap();
464        let config_path = tmp.path().join("config.toml");
465
466        let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
467            .unwrap_err();
468        assert!(err.to_string().contains("not found on PATH"));
469    }
470
471    // --- remove_custom_cli behavior ---
472
473    #[test]
474    fn remove_cli_deletes_entry_from_config_file() {
475        let tmp = TempDir::new().unwrap();
476        let config_path = tmp.path().join("git-paw").join("config.toml");
477
478        // Set up: add two CLIs
479        add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
480        add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
481
482        // Act: remove one
483        remove_custom_cli_from(&config_path, "remove-me").unwrap();
484
485        // Verify: only the kept CLI remains
486        let config = load_config_file(&config_path).unwrap().unwrap();
487        assert_eq!(config.clis.len(), 1);
488        assert!(config.clis.contains_key("keep-me"));
489        assert!(!config.clis.contains_key("remove-me"));
490    }
491
492    #[test]
493    fn remove_nonexistent_cli_returns_cli_not_found_error() {
494        let tmp = TempDir::new().unwrap();
495        let config_path = tmp.path().join("config.toml");
496        // Empty config file
497        write_file(&config_path, "");
498
499        let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
500        match err {
501            PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
502            other => panic!("expected CliNotFound, got: {other}"),
503        }
504    }
505
506    #[test]
507    fn remove_cli_from_empty_config_returns_error() {
508        let tmp = TempDir::new().unwrap();
509        let config_path = tmp.path().join("config.toml");
510        // No file at all
511
512        let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
513        match err {
514            PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
515            other => panic!("expected CliNotFound, got: {other}"),
516        }
517    }
518
519    // --- Round-trip: config survives write + read ---
520
521    #[test]
522    fn config_survives_save_and_load() {
523        let tmp = TempDir::new().unwrap();
524        let config_path = tmp.path().join("config.toml");
525
526        let original = PawConfig {
527            default_cli: Some("claude".into()),
528            mouse: Some(true),
529            clis: HashMap::from([(
530                "test".into(),
531                CustomCli {
532                    command: "/bin/test".into(),
533                    display_name: Some("Test CLI".into()),
534                },
535            )]),
536            presets: HashMap::from([(
537                "dev".into(),
538                Preset {
539                    branches: vec!["main".into()],
540                    cli: "claude".into(),
541                },
542            )]),
543        };
544
545        save_config_to(&config_path, &original).unwrap();
546        let loaded = load_config_file(&config_path).unwrap().unwrap();
547        assert_eq!(original, loaded);
548    }
549}