1use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7pub const DEFAULT_ADR_DIR: &str = "doc/adr";
9
10pub const LEGACY_CONFIG_FILE: &str = ".adr-dir";
12
13pub const CONFIG_FILE: &str = "adrs.toml";
15
16pub const GLOBAL_CONFIG_FILE: &str = "config.toml";
18
19pub const ENV_ADR_DIRECTORY: &str = "ADR_DIRECTORY";
21
22pub const ENV_ADRS_CONFIG: &str = "ADRS_CONFIG";
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28pub struct Config {
29 pub adr_dir: PathBuf,
31
32 pub mode: ConfigMode,
34
35 #[serde(default)]
37 pub templates: TemplateConfig,
38}
39
40impl Default for Config {
41 fn default() -> Self {
42 Self {
43 adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
44 mode: ConfigMode::Compatible,
45 templates: TemplateConfig::default(),
46 }
47 }
48}
49
50impl Config {
51 pub fn load(root: &Path) -> Result<Self> {
58 let config_path = root.join(CONFIG_FILE);
60 if config_path.exists() {
61 let content = std::fs::read_to_string(&config_path)?;
62 let config: Config = toml::from_str(&content)?;
63 if config.adr_dir.as_os_str().is_empty() {
64 return Err(Error::ConfigError(
65 "adr_dir cannot be empty in adrs.toml".into(),
66 ));
67 }
68 return Ok(config);
69 }
70
71 let legacy_path = root.join(LEGACY_CONFIG_FILE);
73 if legacy_path.exists() {
74 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
75 if adr_dir.is_empty() {
76 return Err(Error::ConfigError(
77 "ADR directory path is empty in .adr-dir file".into(),
78 ));
79 }
80 return Ok(Self {
81 adr_dir: PathBuf::from(adr_dir),
82 mode: ConfigMode::Compatible,
83 templates: TemplateConfig::default(),
84 });
85 }
86
87 let default_dir = root.join(DEFAULT_ADR_DIR);
89 if default_dir.exists() {
90 return Ok(Self::default());
91 }
92
93 Err(Error::AdrDirNotFound)
94 }
95
96 pub fn load_or_default(root: &Path) -> Self {
98 Self::load(root).unwrap_or_default()
99 }
100
101 pub fn save(&self, root: &Path) -> Result<()> {
103 match self.mode {
104 ConfigMode::Compatible => {
105 let path = root.join(LEGACY_CONFIG_FILE);
107 std::fs::write(&path, self.adr_dir.display().to_string())?;
108 }
109 ConfigMode::NextGen => {
110 let path = root.join(CONFIG_FILE);
112 let content =
113 toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
114 std::fs::write(&path, content)?;
115 }
116 }
117 Ok(())
118 }
119
120 pub fn adr_path(&self, root: &Path) -> PathBuf {
122 root.join(&self.adr_dir)
123 }
124
125 pub fn is_next_gen(&self) -> bool {
127 matches!(self.mode, ConfigMode::NextGen)
128 }
129
130 pub fn merge(&mut self, other: &Config) {
132 if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
134 self.adr_dir = other.adr_dir.clone();
135 }
136 self.mode = other.mode;
138 if other.templates.format.is_some() {
140 self.templates.format = other.templates.format.clone();
141 }
142 if other.templates.variant.is_some() {
143 self.templates.variant = other.templates.variant.clone();
144 }
145 if other.templates.custom.is_some() {
146 self.templates.custom = other.templates.custom.clone();
147 }
148 }
149}
150
151#[derive(Debug, Clone)]
153pub struct DiscoveredConfig {
154 pub config: Config,
156 pub root: PathBuf,
158 pub source: ConfigSource,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum ConfigSource {
165 Project(PathBuf),
167 Global(PathBuf),
169 Environment,
171 Default,
173}
174
175pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
185 if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
187 let path = PathBuf::from(&config_path);
188 if path.exists() {
189 let content = std::fs::read_to_string(&path)?;
190 let mut config: Config = toml::from_str(&content)?;
191 apply_env_overrides(&mut config);
192 return Ok(DiscoveredConfig {
193 config,
194 root: path
195 .parent()
196 .map(|p| p.to_path_buf())
197 .unwrap_or_else(|| start_dir.to_path_buf()),
198 source: ConfigSource::Environment,
199 });
200 }
201 }
202
203 if let Some((root, config, source)) = search_upward(start_dir)? {
205 let mut config = config;
206 apply_env_overrides(&mut config);
207 return Ok(DiscoveredConfig {
208 config,
209 root,
210 source,
211 });
212 }
213
214 if let Some((config, path)) = load_global_config()? {
216 let mut config = config;
217 apply_env_overrides(&mut config);
218 return Ok(DiscoveredConfig {
219 config,
220 root: start_dir.to_path_buf(),
221 source: ConfigSource::Global(path),
222 });
223 }
224
225 let mut config = Config::default();
227 apply_env_overrides(&mut config);
228 Ok(DiscoveredConfig {
229 config,
230 root: start_dir.to_path_buf(),
231 source: ConfigSource::Default,
232 })
233}
234
235fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
237 let mut current = start_dir.to_path_buf();
238
239 loop {
240 let config_path = current.join(CONFIG_FILE);
242 if config_path.exists() {
243 let content = std::fs::read_to_string(&config_path)?;
244 let config: Config = toml::from_str(&content)?;
245 return Ok(Some((current, config, ConfigSource::Project(config_path))));
246 }
247
248 let legacy_path = current.join(LEGACY_CONFIG_FILE);
250 if legacy_path.exists() {
251 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
252 let config = Config {
253 adr_dir: PathBuf::from(adr_dir),
254 mode: ConfigMode::Compatible,
255 templates: TemplateConfig::default(),
256 };
257 return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
258 }
259
260 let default_dir = current.join(DEFAULT_ADR_DIR);
262 if default_dir.exists() {
263 return Ok(Some((current, Config::default(), ConfigSource::Default)));
264 }
265
266 if current.join(".git").exists() {
268 break;
269 }
270
271 match current.parent() {
273 Some(parent) => current = parent.to_path_buf(),
274 None => break,
275 }
276 }
277
278 Ok(None)
279}
280
281fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
283 let config_dir = dirs_config_dir()?;
284 let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
285
286 if global_path.exists() {
287 let content = std::fs::read_to_string(&global_path)?;
288 let config: Config = toml::from_str(&content)?;
289 return Ok(Some((config, global_path)));
290 }
291
292 Ok(None)
293}
294
295fn dirs_config_dir() -> Result<PathBuf> {
297 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
299 return Ok(PathBuf::from(xdg));
300 }
301
302 if let Ok(home) = std::env::var("HOME") {
303 return Ok(PathBuf::from(home).join(".config"));
304 }
305
306 if let Ok(appdata) = std::env::var("APPDATA") {
308 return Ok(PathBuf::from(appdata));
309 }
310
311 Err(Error::ConfigError(
312 "Could not determine config directory".into(),
313 ))
314}
315
316fn apply_env_overrides(config: &mut Config) {
318 if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
319 config.adr_dir = PathBuf::from(adr_dir);
320 }
321}
322
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum ConfigMode {
327 #[default]
329 Compatible,
330
331 #[serde(rename = "ng", alias = "nextgen")]
333 NextGen,
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338#[serde(default)]
339pub struct TemplateConfig {
340 pub format: Option<String>,
342
343 pub variant: Option<String>,
345
346 pub custom: Option<PathBuf>,
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use tempfile::TempDir;
354 use test_case::test_case;
355
356 #[test]
359 fn test_default_config() {
360 let config = Config::default();
361 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
362 assert_eq!(config.mode, ConfigMode::Compatible);
363 assert!(config.templates.format.is_none());
364 assert!(config.templates.custom.is_none());
365 }
366
367 #[test]
368 fn test_constants() {
369 assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
370 assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
371 assert_eq!(CONFIG_FILE, "adrs.toml");
372 }
373
374 #[test]
375 fn test_config_mode_default() {
376 assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
377 }
378
379 #[test]
382 fn test_load_legacy_config() {
383 let temp = TempDir::new().unwrap();
384 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
385
386 let config = Config::load(temp.path()).unwrap();
387 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
388 assert_eq!(config.mode, ConfigMode::Compatible);
389 }
390
391 #[test]
392 fn test_load_legacy_config_with_whitespace() {
393 let temp = TempDir::new().unwrap();
394 std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
395
396 let config = Config::load(temp.path()).unwrap();
397 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
398 }
399
400 #[test]
401 fn test_load_legacy_config_nested_path() {
402 let temp = TempDir::new().unwrap();
403 std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
404
405 let config = Config::load(temp.path()).unwrap();
406 assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
407 }
408
409 #[test]
410 fn test_load_new_config() {
411 let temp = TempDir::new().unwrap();
412 std::fs::write(
413 temp.path().join("adrs.toml"),
414 r#"
415adr_dir = "docs/decisions"
416mode = "ng"
417"#,
418 )
419 .unwrap();
420
421 let config = Config::load(temp.path()).unwrap();
422 assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
423 assert_eq!(config.mode, ConfigMode::NextGen);
424 }
425
426 #[test]
427 fn test_load_new_config_compatible_mode() {
428 let temp = TempDir::new().unwrap();
429 std::fs::write(
430 temp.path().join("adrs.toml"),
431 r#"
432adr_dir = "doc/adr"
433mode = "compatible"
434"#,
435 )
436 .unwrap();
437
438 let config = Config::load(temp.path()).unwrap();
439 assert_eq!(config.mode, ConfigMode::Compatible);
440 }
441
442 #[test]
443 fn test_load_new_config_with_templates() {
444 let temp = TempDir::new().unwrap();
445 std::fs::write(
446 temp.path().join("adrs.toml"),
447 r#"
448adr_dir = "decisions"
449mode = "ng"
450
451[templates]
452format = "markdown"
453custom = "templates/adr.md"
454"#,
455 )
456 .unwrap();
457
458 let config = Config::load(temp.path()).unwrap();
459 assert_eq!(config.templates.format, Some("markdown".to_string()));
460 assert_eq!(
461 config.templates.custom,
462 Some(PathBuf::from("templates/adr.md"))
463 );
464 }
465
466 #[test]
467 fn test_load_new_config_with_template_variant() {
468 let temp = TempDir::new().unwrap();
469 std::fs::write(
470 temp.path().join("adrs.toml"),
471 r#"
472adr_dir = "decisions"
473mode = "ng"
474
475[templates]
476format = "madr"
477variant = "minimal"
478"#,
479 )
480 .unwrap();
481
482 let config = Config::load(temp.path()).unwrap();
483 assert_eq!(config.templates.format, Some("madr".to_string()));
484 assert_eq!(config.templates.variant, Some("minimal".to_string()));
485 }
486
487 #[test]
488 fn test_load_new_config_with_nextgen_alias() {
489 let temp = TempDir::new().unwrap();
490 std::fs::write(
491 temp.path().join("adrs.toml"),
492 r#"
493adr_dir = "decisions"
494mode = "nextgen"
495"#,
496 )
497 .unwrap();
498
499 let config = Config::load(temp.path()).unwrap();
500 assert_eq!(config.mode, ConfigMode::NextGen);
501 }
502
503 #[test]
504 fn test_load_new_config_minimal() {
505 let temp = TempDir::new().unwrap();
506 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
507
508 let config = Config::load(temp.path()).unwrap();
509 assert_eq!(config.adr_dir, PathBuf::from("adrs"));
510 assert_eq!(config.mode, ConfigMode::Compatible);
512 }
513
514 #[test]
515 fn test_load_prefers_new_config_over_legacy() {
516 let temp = TempDir::new().unwrap();
517 std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
519 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
520
521 let config = Config::load(temp.path()).unwrap();
522 assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
524 }
525
526 #[test]
527 fn test_load_default_dir_exists() {
528 let temp = TempDir::new().unwrap();
529 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
531
532 let config = Config::load(temp.path()).unwrap();
533 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
534 }
535
536 #[test]
537 fn test_load_no_config_no_default_dir() {
538 let temp = TempDir::new().unwrap();
539 let result = Config::load(temp.path());
542 assert!(result.is_err());
543 }
544
545 #[test]
546 fn test_load_or_default_returns_default_on_error() {
547 let temp = TempDir::new().unwrap();
548 let config = Config::load_or_default(temp.path());
551 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
552 assert_eq!(config.mode, ConfigMode::Compatible);
553 }
554
555 #[test]
556 fn test_load_or_default_returns_config_when_exists() {
557 let temp = TempDir::new().unwrap();
558 std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
559
560 let config = Config::load_or_default(temp.path());
561 assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
562 }
563
564 #[test]
567 fn test_save_legacy_config() {
568 let temp = TempDir::new().unwrap();
569 let config = Config {
570 adr_dir: PathBuf::from("my/adrs"),
571 mode: ConfigMode::Compatible,
572 templates: TemplateConfig::default(),
573 };
574
575 config.save(temp.path()).unwrap();
576
577 let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
578 assert_eq!(content, "my/adrs");
579 assert!(!temp.path().join("adrs.toml").exists());
581 }
582
583 #[test]
584 fn test_save_new_config() {
585 let temp = TempDir::new().unwrap();
586 let config = Config {
587 adr_dir: PathBuf::from("docs/decisions"),
588 mode: ConfigMode::NextGen,
589 templates: TemplateConfig::default(),
590 };
591
592 config.save(temp.path()).unwrap();
593
594 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
595 assert!(content.contains("docs/decisions"));
596 assert!(content.contains("ng"));
597 assert!(!temp.path().join(".adr-dir").exists());
599 }
600
601 #[test]
602 fn test_save_new_config_with_templates() {
603 let temp = TempDir::new().unwrap();
604 let config = Config {
605 adr_dir: PathBuf::from("decisions"),
606 mode: ConfigMode::NextGen,
607 templates: TemplateConfig {
608 format: Some("custom".to_string()),
609 variant: None,
610 custom: Some(PathBuf::from("my-template.md")),
611 },
612 };
613
614 config.save(temp.path()).unwrap();
615
616 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
617 assert!(content.contains("custom"));
618 assert!(content.contains("my-template.md"));
619 }
620
621 #[test]
622 fn test_save_and_load_roundtrip_compatible() {
623 let temp = TempDir::new().unwrap();
624 let original = Config {
625 adr_dir: PathBuf::from("architecture/decisions"),
626 mode: ConfigMode::Compatible,
627 templates: TemplateConfig::default(),
628 };
629
630 original.save(temp.path()).unwrap();
631 let loaded = Config::load(temp.path()).unwrap();
632
633 assert_eq!(loaded.adr_dir, original.adr_dir);
634 assert_eq!(loaded.mode, ConfigMode::Compatible);
635 }
636
637 #[test]
638 fn test_save_and_load_roundtrip_nextgen() {
639 let temp = TempDir::new().unwrap();
640 let original = Config {
641 adr_dir: PathBuf::from("docs/adr"),
642 mode: ConfigMode::NextGen,
643 templates: TemplateConfig {
644 format: Some("markdown".to_string()),
645 variant: None,
646 custom: None,
647 },
648 };
649
650 original.save(temp.path()).unwrap();
651 let loaded = Config::load(temp.path()).unwrap();
652
653 assert_eq!(loaded.adr_dir, original.adr_dir);
654 assert_eq!(loaded.mode, ConfigMode::NextGen);
655 assert_eq!(loaded.templates.format, Some("markdown".to_string()));
656 }
657
658 #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
661 #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
662 #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
663 fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
664 let config = Config {
665 adr_dir: PathBuf::from(adr_dir),
666 ..Default::default()
667 };
668 config.adr_path(Path::new(root))
669 }
670
671 #[test]
672 fn test_is_next_gen() {
673 let compatible = Config {
674 mode: ConfigMode::Compatible,
675 ..Default::default()
676 };
677 assert!(!compatible.is_next_gen());
678
679 let nextgen = Config {
680 mode: ConfigMode::NextGen,
681 ..Default::default()
682 };
683 assert!(nextgen.is_next_gen());
684 }
685
686 #[test]
689 fn test_config_mode_equality() {
690 assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
691 assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
692 assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
693 }
694
695 #[test]
696 fn test_config_mode_serialization_in_config() {
697 let config = Config {
699 mode: ConfigMode::Compatible,
700 ..Default::default()
701 };
702 let toml = toml::to_string(&config).unwrap();
703 assert!(toml.contains("mode = \"compatible\""));
704
705 let config = Config {
706 mode: ConfigMode::NextGen,
707 ..Default::default()
708 };
709 let toml = toml::to_string(&config).unwrap();
710 assert!(toml.contains("mode = \"ng\""));
711 }
712
713 #[test]
714 fn test_config_mode_deserialization_in_config() {
715 let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
716 assert_eq!(config.mode, ConfigMode::Compatible);
717
718 let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
719 assert_eq!(config.mode, ConfigMode::NextGen);
720 }
721
722 #[test]
723 fn test_config_mode_deserialization_nextgen_alias() {
724 let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
725 assert_eq!(config.mode, ConfigMode::NextGen);
726 }
727
728 #[test]
731 fn test_template_config_default() {
732 let config = TemplateConfig::default();
733 assert!(config.format.is_none());
734 assert!(config.variant.is_none());
735 assert!(config.custom.is_none());
736 }
737
738 #[test]
739 fn test_template_config_serialization() {
740 let config = TemplateConfig {
741 format: Some("nygard".to_string()),
742 variant: None,
743 custom: Some(PathBuf::from("templates/custom.md")),
744 };
745
746 let toml = toml::to_string(&config).unwrap();
747 assert!(toml.contains("nygard"));
748 assert!(toml.contains("templates/custom.md"));
749 }
750
751 #[test]
754 fn test_load_invalid_toml() {
755 let temp = TempDir::new().unwrap();
756 std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
757
758 let result = Config::load(temp.path());
759 assert!(result.is_err());
760 }
761
762 #[test]
763 fn test_load_empty_toml() {
764 let temp = TempDir::new().unwrap();
765 std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
766
767 let config = Config::load(temp.path()).unwrap();
769 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
770 }
771
772 #[test]
773 fn test_load_empty_adr_dir_file() {
774 let temp = TempDir::new().unwrap();
775 std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
776
777 let result = Config::load(temp.path());
778 assert!(result.is_err(), "Empty .adr-dir should produce an error");
779 }
780
781 #[test]
784 fn test_discover_finds_config_in_current_dir() {
785 let temp = TempDir::new().unwrap();
786 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
787
788 let discovered = discover(temp.path()).unwrap();
789 assert_eq!(discovered.root, temp.path());
790 assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
791 assert!(matches!(discovered.source, ConfigSource::Project(_)));
792 }
793
794 #[test]
795 fn test_discover_finds_config_in_parent_dir() {
796 let temp = TempDir::new().unwrap();
797 let subdir = temp.path().join("src").join("lib");
798 std::fs::create_dir_all(&subdir).unwrap();
799 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
800
801 let discovered = discover(&subdir).unwrap();
802 assert_eq!(discovered.root, temp.path());
803 assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
804 }
805
806 #[test]
807 fn test_discover_stops_at_git_root() {
808 let temp = TempDir::new().unwrap();
809
810 std::fs::create_dir(temp.path().join(".git")).unwrap();
812 let subdir = temp.path().join("src");
813 std::fs::create_dir(&subdir).unwrap();
814
815 let result = discover(&subdir);
819 assert!(result.is_ok());
821 let discovered = result.unwrap();
822 assert!(matches!(discovered.source, ConfigSource::Default));
823 }
824
825 #[test]
826 fn test_discover_prefers_adrs_toml_over_adr_dir() {
827 let temp = TempDir::new().unwrap();
828 std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
829 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
830
831 let discovered = discover(temp.path()).unwrap();
832 assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
833 }
834
835 #[test]
836 fn test_discover_finds_default_adr_dir() {
837 let temp = TempDir::new().unwrap();
838 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
839
840 let discovered = discover(temp.path()).unwrap();
841 assert_eq!(discovered.root, temp.path());
842 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
843 }
844
845 #[test]
846 fn test_discover_returns_defaults_when_nothing_found() {
847 let temp = TempDir::new().unwrap();
848 std::fs::create_dir(temp.path().join(".git")).unwrap();
850
851 let discovered = discover(temp.path()).unwrap();
852 assert!(matches!(discovered.source, ConfigSource::Default));
853 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
854 }
855
856 #[test]
857 fn test_apply_env_overrides() {
858 let mut config = Config::default();
862 apply_env_overrides(&mut config);
863 assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
865 }
866
867 #[test]
868 fn test_config_source_variants() {
869 let project = ConfigSource::Project(PathBuf::from("test"));
871 let global = ConfigSource::Global(PathBuf::from("test"));
872 let env = ConfigSource::Environment;
873 let default = ConfigSource::Default;
874
875 assert_ne!(project, global);
876 assert_ne!(env, default);
877 assert_eq!(default, ConfigSource::Default);
878 }
879
880 #[test]
881 fn test_config_merge() {
882 let mut base = Config::default();
883 let other = Config {
884 adr_dir: PathBuf::from("custom"),
885 mode: ConfigMode::NextGen,
886 templates: TemplateConfig {
887 format: Some("madr".to_string()),
888 variant: None,
889 custom: None,
890 },
891 };
892
893 base.merge(&other);
894 assert_eq!(base.adr_dir, PathBuf::from("custom"));
895 assert_eq!(base.mode, ConfigMode::NextGen);
896 assert_eq!(base.templates.format, Some("madr".to_string()));
897 }
898
899 #[test]
900 fn test_config_merge_preserves_default_adr_dir() {
901 let mut base = Config {
902 adr_dir: PathBuf::from("original"),
903 ..Default::default()
904 };
905 let other = Config::default(); base.merge(&other);
908 assert_eq!(base.adr_dir, PathBuf::from("original"));
910 }
911
912 #[test]
915 fn test_load_empty_adr_dir_in_toml() {
916 let temp = TempDir::new().unwrap();
917 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
918
919 let result = Config::load(temp.path());
920 assert!(
921 result.is_err(),
922 "Empty adr_dir in TOML should produce an error"
923 );
924 }
925
926 #[test]
927 fn test_load_whitespace_only_adr_dir_file() {
928 let temp = TempDir::new().unwrap();
929 std::fs::write(temp.path().join(".adr-dir"), " \n ").unwrap();
930
931 let result = Config::load(temp.path());
932 assert!(
933 result.is_err(),
934 "Whitespace-only .adr-dir should produce an error"
935 );
936 }
937
938 #[test]
939 fn test_invalid_format_string_accepted_in_toml() {
940 let temp = TempDir::new().unwrap();
944 std::fs::write(
945 temp.path().join("adrs.toml"),
946 r#"
947adr_dir = "doc/adr"
948
949[templates]
950format = "nonexistent"
951"#,
952 )
953 .unwrap();
954
955 let config = Config::load(temp.path()).unwrap();
956 assert_eq!(config.templates.format, Some("nonexistent".to_string()));
957 }
958
959 #[test]
960 fn test_invalid_variant_string_accepted_in_toml() {
961 let temp = TempDir::new().unwrap();
962 std::fs::write(
963 temp.path().join("adrs.toml"),
964 r#"
965adr_dir = "doc/adr"
966
967[templates]
968variant = "bogus"
969"#,
970 )
971 .unwrap();
972
973 let config = Config::load(temp.path()).unwrap();
974 assert_eq!(config.templates.variant, Some("bogus".to_string()));
975 }
976
977 #[test]
978 fn test_invalid_mode_string_rejected() {
979 let temp = TempDir::new().unwrap();
980 std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
981
982 let result = Config::load(temp.path());
983 assert!(
984 result.is_err(),
985 "Invalid mode should produce a TOML parse error"
986 );
987 }
988
989 #[test]
990 fn test_unknown_toml_fields_accepted() {
991 let temp = TempDir::new().unwrap();
993 std::fs::write(
994 temp.path().join("adrs.toml"),
995 r#"
996adr_dir = "doc/adr"
997unknown_field = "hello"
998
999[templates]
1000also_unknown = true
1001"#,
1002 )
1003 .unwrap();
1004
1005 let config = Config::load(temp.path()).unwrap();
1006 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1007 }
1008
1009 #[test]
1010 fn test_custom_template_path_in_config() {
1011 let temp = TempDir::new().unwrap();
1012 std::fs::write(
1013 temp.path().join("adrs.toml"),
1014 r#"
1015adr_dir = "doc/adr"
1016mode = "ng"
1017
1018[templates]
1019custom = "templates/my-adr.md"
1020"#,
1021 )
1022 .unwrap();
1023
1024 let config = Config::load(temp.path()).unwrap();
1025 assert_eq!(
1026 config.templates.custom,
1027 Some(PathBuf::from("templates/my-adr.md"))
1028 );
1029 }
1030
1031 #[test]
1034 fn test_save_and_load_roundtrip_nextgen_with_templates() {
1035 let temp = TempDir::new().unwrap();
1036 let original = Config {
1037 adr_dir: PathBuf::from("docs/decisions"),
1038 mode: ConfigMode::NextGen,
1039 templates: TemplateConfig {
1040 format: Some("madr".to_string()),
1041 variant: Some("minimal".to_string()),
1042 custom: Some(PathBuf::from("templates/custom.md")),
1043 },
1044 };
1045
1046 original.save(temp.path()).unwrap();
1047 let loaded = Config::load(temp.path()).unwrap();
1048
1049 assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1050 assert_eq!(loaded.mode, ConfigMode::NextGen);
1051 assert_eq!(loaded.templates.format, Some("madr".to_string()));
1052 assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1053 assert_eq!(
1054 loaded.templates.custom,
1055 Some(PathBuf::from("templates/custom.md"))
1056 );
1057 }
1058
1059 #[test]
1060 fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1061 let temp = TempDir::new().unwrap();
1063 let original = Config {
1064 mode: ConfigMode::NextGen,
1065 ..Default::default()
1066 };
1067
1068 original.save(temp.path()).unwrap();
1069
1070 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1072 assert!(content.contains(r#"mode = "ng""#));
1073
1074 let loaded = Config::load(temp.path()).unwrap();
1076 assert_eq!(loaded.mode, ConfigMode::NextGen);
1077 }
1078
1079 #[test]
1082 fn test_config_merge_variant_field() {
1083 let mut base = Config::default();
1084 let other = Config {
1085 templates: TemplateConfig {
1086 format: None,
1087 variant: Some("minimal".to_string()),
1088 custom: None,
1089 },
1090 ..Default::default()
1091 };
1092
1093 base.merge(&other);
1094 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1095 }
1096
1097 #[test]
1098 fn test_config_merge_custom_field() {
1099 let mut base = Config::default();
1100 let other = Config {
1101 templates: TemplateConfig {
1102 format: None,
1103 variant: None,
1104 custom: Some(PathBuf::from("my-template.md")),
1105 },
1106 ..Default::default()
1107 };
1108
1109 base.merge(&other);
1110 assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1111 }
1112
1113 #[test]
1114 fn test_config_merge_does_not_overwrite_with_none() {
1115 let mut base = Config {
1116 templates: TemplateConfig {
1117 format: Some("madr".to_string()),
1118 variant: Some("minimal".to_string()),
1119 custom: Some(PathBuf::from("template.md")),
1120 },
1121 ..Default::default()
1122 };
1123 let other = Config::default(); base.merge(&other);
1126
1127 assert_eq!(base.templates.format, Some("madr".to_string()));
1129 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1130 assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1131 }
1132}