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, Serialize, Deserialize, PartialEq, Eq)]
55pub struct BrokerConfig {
56 #[serde(default)]
58 pub enabled: bool,
59 #[serde(default = "BrokerConfig::default_port")]
61 pub port: u16,
62 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct PawConfig {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub default_cli: Option<String>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub default_spec_cli: Option<String>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub branch_prefix: Option<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub mouse: Option<bool>,
112
113 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
115 pub clis: HashMap<String, CustomCli>,
116
117 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
119 pub presets: HashMap<String, Preset>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub specs: Option<SpecsConfig>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub logging: Option<LoggingConfig>,
128
129 #[serde(default)]
131 pub broker: BrokerConfig,
132}
133
134impl PawConfig {
135 #[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 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
179 self.presets.get(name)
180 }
181}
182
183pub 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
190pub fn repo_config_path(repo_root: &Path) -> PathBuf {
192 repo_root.join(".git-paw").join("config.toml")
193}
194
195fn 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
208pub 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
216pub 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
225pub 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
232pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
234 save_config_to(&repo_config_path(repo_root), config)
235}
236
237fn 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 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
257pub 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
268pub 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
299pub 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
356pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
360 remove_custom_cli_from(&global_config_path()?, name)
361}
362
363pub 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 #[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 #[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", );
496
497 let config = load_config_from(&global_path, &repo_root).unwrap();
498 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
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 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 #[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 #[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_custom_cli_to(
609 &config_path,
610 "my-agent",
611 "/usr/local/bin/my-agent",
612 Some("My Agent"),
613 )
614 .unwrap();
615
616 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 #[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 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 remove_custom_cli_from(&config_path, "remove-me").unwrap();
663
664 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}