1use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::PawError;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct CustomCli {
18 pub command: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub display_name: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Preset {
28 pub branches: Vec<String>,
30 pub cli: String,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct PawConfig {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub default_cli: Option<String>,
42
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub mouse: Option<bool>,
46
47 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49 pub clis: HashMap<String, CustomCli>,
50
51 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53 pub presets: HashMap<String, Preset>,
54}
55
56impl PawConfig {
57 #[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 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
86 self.presets.get(name)
87 }
88}
89
90pub 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
97pub fn repo_config_path(repo_root: &Path) -> PathBuf {
99 repo_root.join(".git-paw").join("config.toml")
100}
101
102fn 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
115pub 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
124pub 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
131fn 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 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
151pub 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
162pub 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
193pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
197 remove_custom_cli_from(&global_config_path()?, name)
198}
199
200pub 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 #[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 #[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", );
317
318 let config = load_config_from(&global_path, &repo_root).unwrap();
319 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
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 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 #[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 #[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_custom_cli_to(
430 &config_path,
431 "my-agent",
432 "/usr/local/bin/my-agent",
433 Some("My Agent"),
434 )
435 .unwrap();
436
437 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 #[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 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 remove_custom_cli_from(&config_path, "remove-me").unwrap();
484
485 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 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 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 #[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}