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)]
36pub struct SpecsConfig {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub dir: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
42 pub spec_type: Option<String>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
47pub struct LoggingConfig {
48 #[serde(default)]
50 pub enabled: bool,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
57pub struct PawConfig {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub default_cli: Option<String>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub default_spec_cli: Option<String>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub branch_prefix: Option<String>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub mouse: Option<bool>,
73
74 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
76 pub clis: HashMap<String, CustomCli>,
77
78 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80 pub presets: HashMap<String, Preset>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub specs: Option<SpecsConfig>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub logging: Option<LoggingConfig>,
89}
90
91impl PawConfig {
92 #[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 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
131 self.presets.get(name)
132 }
133}
134
135pub 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
142pub fn repo_config_path(repo_root: &Path) -> PathBuf {
144 repo_root.join(".git-paw").join("config.toml")
145}
146
147fn 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
160pub 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
168pub 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
177pub 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
184pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
186 save_config_to(&repo_config_path(repo_root), config)
187}
188
189fn 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 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
209pub 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
220pub 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
251pub 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
302pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
306 remove_custom_cli_from(&global_config_path()?, name)
307}
308
309pub 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 #[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 #[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", );
442
443 let config = load_config_from(&global_path, &repo_root).unwrap();
444 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
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 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 #[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 #[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_custom_cli_to(
555 &config_path,
556 "my-agent",
557 "/usr/local/bin/my-agent",
558 Some("My Agent"),
559 )
560 .unwrap();
561
562 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 #[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 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 remove_custom_cli_from(&config_path, "remove-me").unwrap();
609
610 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}