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 pub default_status: Option<String>,
37
38 pub no_edit: bool,
40
41 #[serde(default)]
43 pub templates: TemplateConfig,
44
45 #[serde(default)]
47 pub generate: GenerateConfig,
48
49 #[serde(default)]
51 pub export: ExportConfig,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
58 mode: ConfigMode::Compatible,
59 default_status: None,
60 no_edit: false,
61 templates: TemplateConfig::default(),
62 generate: GenerateConfig::default(),
63 export: ExportConfig::default(),
64 }
65 }
66}
67
68impl Config {
69 pub fn load(root: &Path) -> Result<Self> {
76 let config_path = root.join(CONFIG_FILE);
78 if config_path.exists() {
79 let content = std::fs::read_to_string(&config_path)?;
80 let config: Config = toml::from_str(&content)?;
81 if config.adr_dir.as_os_str().is_empty() {
82 return Err(Error::ConfigError(
83 "adr_dir cannot be empty in adrs.toml".into(),
84 ));
85 }
86 return Ok(config);
87 }
88
89 let legacy_path = root.join(LEGACY_CONFIG_FILE);
91 if legacy_path.exists() {
92 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
93 if adr_dir.is_empty() {
94 return Err(Error::ConfigError(
95 "ADR directory path is empty in .adr-dir file".into(),
96 ));
97 }
98 return Ok(Self {
99 adr_dir: PathBuf::from(adr_dir),
100 mode: ConfigMode::Compatible,
101 default_status: None,
102 no_edit: false,
103 templates: TemplateConfig::default(),
104 generate: GenerateConfig::default(),
105 export: ExportConfig::default(),
106 });
107 }
108
109 let default_dir = root.join(DEFAULT_ADR_DIR);
111 if default_dir.exists() {
112 return Ok(Self::default());
113 }
114
115 Err(Error::AdrDirNotFound)
116 }
117
118 pub fn load_or_default(root: &Path) -> Self {
120 Self::load(root).unwrap_or_default()
121 }
122
123 pub fn save(&self, root: &Path) -> Result<()> {
125 match self.mode {
126 ConfigMode::Compatible => {
127 let path = root.join(LEGACY_CONFIG_FILE);
129 std::fs::write(&path, self.adr_dir.display().to_string())?;
130 }
131 ConfigMode::NextGen => {
132 let path = root.join(CONFIG_FILE);
134 let content =
135 toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
136 std::fs::write(&path, content)?;
137 }
138 }
139 Ok(())
140 }
141
142 pub fn adr_path(&self, root: &Path) -> PathBuf {
144 root.join(&self.adr_dir)
145 }
146
147 pub fn is_next_gen(&self) -> bool {
149 matches!(self.mode, ConfigMode::NextGen)
150 }
151
152 pub fn merge(&mut self, other: &Config) {
154 if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
156 self.adr_dir = other.adr_dir.clone();
157 }
158 self.mode = other.mode;
160 if other.templates.format.is_some() {
162 self.templates.format = other.templates.format.clone();
163 }
164 if other.templates.variant.is_some() {
165 self.templates.variant = other.templates.variant.clone();
166 }
167 if other.templates.custom.is_some() {
168 self.templates.custom = other.templates.custom.clone();
169 }
170 if other.default_status.is_some() {
171 self.default_status = other.default_status.clone();
172 }
173 if other.no_edit {
174 self.no_edit = other.no_edit;
175 }
176 if other.generate.toc_prefix.is_some() {
177 self.generate.toc_prefix = other.generate.toc_prefix.clone();
178 }
179 if other.export.base_url.is_some() {
180 self.export.base_url = other.export.base_url.clone();
181 }
182 }
183}
184
185#[derive(Debug, Clone)]
187pub struct DiscoveredConfig {
188 pub config: Config,
190 pub root: PathBuf,
192 pub source: ConfigSource,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ConfigSource {
199 Project(PathBuf),
201 Global(PathBuf),
203 Environment,
205 Default,
207}
208
209pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
219 if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
221 let path = PathBuf::from(&config_path);
222 if path.exists() {
223 let content = std::fs::read_to_string(&path)?;
224 let mut config: Config = toml::from_str(&content)?;
225 apply_env_overrides(&mut config);
226 return Ok(DiscoveredConfig {
227 config,
228 root: path
229 .parent()
230 .map(|p| p.to_path_buf())
231 .unwrap_or_else(|| start_dir.to_path_buf()),
232 source: ConfigSource::Environment,
233 });
234 }
235 }
236
237 if let Some((root, config, source)) = search_upward(start_dir)? {
239 let mut config = config;
240 apply_env_overrides(&mut config);
241 return Ok(DiscoveredConfig {
242 config,
243 root,
244 source,
245 });
246 }
247
248 if let Some((config, path)) = load_global_config()? {
250 let mut config = config;
251 apply_env_overrides(&mut config);
252 return Ok(DiscoveredConfig {
253 config,
254 root: start_dir.to_path_buf(),
255 source: ConfigSource::Global(path),
256 });
257 }
258
259 let mut config = Config::default();
261 apply_env_overrides(&mut config);
262 Ok(DiscoveredConfig {
263 config,
264 root: start_dir.to_path_buf(),
265 source: ConfigSource::Default,
266 })
267}
268
269fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
271 let mut current = start_dir.to_path_buf();
272
273 loop {
274 let config_path = current.join(CONFIG_FILE);
276 if config_path.exists() {
277 let content = std::fs::read_to_string(&config_path)?;
278 let config: Config = toml::from_str(&content)?;
279 return Ok(Some((current, config, ConfigSource::Project(config_path))));
280 }
281
282 let legacy_path = current.join(LEGACY_CONFIG_FILE);
284 if legacy_path.exists() {
285 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
286 let config = Config {
287 adr_dir: PathBuf::from(adr_dir),
288 mode: ConfigMode::Compatible,
289 default_status: None,
290 no_edit: false,
291 templates: TemplateConfig::default(),
292 generate: GenerateConfig::default(),
293 export: ExportConfig::default(),
294 };
295 return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
296 }
297
298 let default_dir = current.join(DEFAULT_ADR_DIR);
300 if default_dir.exists() {
301 return Ok(Some((current, Config::default(), ConfigSource::Default)));
302 }
303
304 if current.join(".git").exists() {
306 break;
307 }
308
309 match current.parent() {
311 Some(parent) => current = parent.to_path_buf(),
312 None => break,
313 }
314 }
315
316 Ok(None)
317}
318
319fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
321 let config_dir = dirs_config_dir()?;
322 let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
323
324 if global_path.exists() {
325 let content = std::fs::read_to_string(&global_path)?;
326 let config: Config = toml::from_str(&content)?;
327 return Ok(Some((config, global_path)));
328 }
329
330 Ok(None)
331}
332
333fn dirs_config_dir() -> Result<PathBuf> {
335 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
337 return Ok(PathBuf::from(xdg));
338 }
339
340 if let Ok(home) = std::env::var("HOME") {
341 return Ok(PathBuf::from(home).join(".config"));
342 }
343
344 if let Ok(appdata) = std::env::var("APPDATA") {
346 return Ok(PathBuf::from(appdata));
347 }
348
349 Err(Error::ConfigError(
350 "Could not determine config directory".into(),
351 ))
352}
353
354fn apply_env_overrides(config: &mut Config) {
356 if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
357 config.adr_dir = PathBuf::from(adr_dir);
358 }
359}
360
361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum ConfigMode {
365 #[default]
367 Compatible,
368
369 #[serde(rename = "ng", alias = "nextgen")]
371 NextGen,
372}
373
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376#[serde(default)]
377pub struct TemplateConfig {
378 pub format: Option<String>,
380
381 pub variant: Option<String>,
383
384 pub custom: Option<PathBuf>,
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390#[serde(default)]
391pub struct GenerateConfig {
392 pub toc_prefix: Option<String>,
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398#[serde(default)]
399pub struct ExportConfig {
400 pub base_url: Option<String>,
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use tempfile::TempDir;
408 use test_case::test_case;
409
410 #[test]
413 fn test_default_config() {
414 let config = Config::default();
415 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
416 assert_eq!(config.mode, ConfigMode::Compatible);
417 assert!(config.default_status.is_none());
418 assert!(config.templates.format.is_none());
419 assert!(config.templates.custom.is_none());
420 }
421
422 #[test]
423 fn test_constants() {
424 assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
425 assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
426 assert_eq!(CONFIG_FILE, "adrs.toml");
427 }
428
429 #[test]
430 fn test_config_mode_default() {
431 assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
432 }
433
434 #[test]
437 fn test_load_legacy_config() {
438 let temp = TempDir::new().unwrap();
439 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
440
441 let config = Config::load(temp.path()).unwrap();
442 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
443 assert_eq!(config.mode, ConfigMode::Compatible);
444 }
445
446 #[test]
447 fn test_load_legacy_config_with_whitespace() {
448 let temp = TempDir::new().unwrap();
449 std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
450
451 let config = Config::load(temp.path()).unwrap();
452 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
453 }
454
455 #[test]
456 fn test_load_legacy_config_nested_path() {
457 let temp = TempDir::new().unwrap();
458 std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
459
460 let config = Config::load(temp.path()).unwrap();
461 assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
462 }
463
464 #[test]
465 fn test_load_new_config() {
466 let temp = TempDir::new().unwrap();
467 std::fs::write(
468 temp.path().join("adrs.toml"),
469 r#"
470adr_dir = "docs/decisions"
471mode = "ng"
472"#,
473 )
474 .unwrap();
475
476 let config = Config::load(temp.path()).unwrap();
477 assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
478 assert_eq!(config.mode, ConfigMode::NextGen);
479 }
480
481 #[test]
482 fn test_load_new_config_compatible_mode() {
483 let temp = TempDir::new().unwrap();
484 std::fs::write(
485 temp.path().join("adrs.toml"),
486 r#"
487adr_dir = "doc/adr"
488mode = "compatible"
489"#,
490 )
491 .unwrap();
492
493 let config = Config::load(temp.path()).unwrap();
494 assert_eq!(config.mode, ConfigMode::Compatible);
495 }
496
497 #[test]
498 fn test_load_new_config_with_default_status() {
499 let temp = TempDir::new().unwrap();
500 std::fs::write(
501 temp.path().join("adrs.toml"),
502 r#"
503adr_dir = "doc/adr"
504default_status = "accepted"
505"#,
506 )
507 .unwrap();
508
509 let config = Config::load(temp.path()).unwrap();
510 assert_eq!(config.default_status, Some("accepted".to_string()));
511 }
512
513 #[test]
514 fn test_load_new_config_with_templates() {
515 let temp = TempDir::new().unwrap();
516 std::fs::write(
517 temp.path().join("adrs.toml"),
518 r#"
519adr_dir = "decisions"
520mode = "ng"
521
522[templates]
523format = "markdown"
524custom = "templates/adr.md"
525"#,
526 )
527 .unwrap();
528
529 let config = Config::load(temp.path()).unwrap();
530 assert_eq!(config.templates.format, Some("markdown".to_string()));
531 assert_eq!(
532 config.templates.custom,
533 Some(PathBuf::from("templates/adr.md"))
534 );
535 }
536
537 #[test]
538 fn test_load_new_config_with_template_variant() {
539 let temp = TempDir::new().unwrap();
540 std::fs::write(
541 temp.path().join("adrs.toml"),
542 r#"
543adr_dir = "decisions"
544mode = "ng"
545
546[templates]
547format = "madr"
548variant = "minimal"
549"#,
550 )
551 .unwrap();
552
553 let config = Config::load(temp.path()).unwrap();
554 assert_eq!(config.templates.format, Some("madr".to_string()));
555 assert_eq!(config.templates.variant, Some("minimal".to_string()));
556 }
557
558 #[test]
559 fn test_load_new_config_with_nextgen_alias() {
560 let temp = TempDir::new().unwrap();
561 std::fs::write(
562 temp.path().join("adrs.toml"),
563 r#"
564adr_dir = "decisions"
565mode = "nextgen"
566"#,
567 )
568 .unwrap();
569
570 let config = Config::load(temp.path()).unwrap();
571 assert_eq!(config.mode, ConfigMode::NextGen);
572 }
573
574 #[test]
575 fn test_load_new_config_minimal() {
576 let temp = TempDir::new().unwrap();
577 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
578
579 let config = Config::load(temp.path()).unwrap();
580 assert_eq!(config.adr_dir, PathBuf::from("adrs"));
581 assert_eq!(config.mode, ConfigMode::Compatible);
583 }
584
585 #[test]
586 fn test_load_prefers_new_config_over_legacy() {
587 let temp = TempDir::new().unwrap();
588 std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
590 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
591
592 let config = Config::load(temp.path()).unwrap();
593 assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
595 }
596
597 #[test]
598 fn test_load_default_dir_exists() {
599 let temp = TempDir::new().unwrap();
600 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
602
603 let config = Config::load(temp.path()).unwrap();
604 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
605 }
606
607 #[test]
608 fn test_load_no_config_no_default_dir() {
609 let temp = TempDir::new().unwrap();
610 let result = Config::load(temp.path());
613 assert!(result.is_err());
614 }
615
616 #[test]
617 fn test_load_or_default_returns_default_on_error() {
618 let temp = TempDir::new().unwrap();
619 let config = Config::load_or_default(temp.path());
622 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
623 assert_eq!(config.mode, ConfigMode::Compatible);
624 }
625
626 #[test]
627 fn test_load_or_default_returns_config_when_exists() {
628 let temp = TempDir::new().unwrap();
629 std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
630
631 let config = Config::load_or_default(temp.path());
632 assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
633 }
634
635 #[test]
638 fn test_save_legacy_config() {
639 let temp = TempDir::new().unwrap();
640 let config = Config {
641 adr_dir: PathBuf::from("my/adrs"),
642 mode: ConfigMode::Compatible,
643 default_status: None,
644 no_edit: false,
645 templates: TemplateConfig::default(),
646 generate: GenerateConfig::default(),
647 export: ExportConfig::default(),
648 };
649
650 config.save(temp.path()).unwrap();
651
652 let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
653 assert_eq!(content, "my/adrs");
654 assert!(!temp.path().join("adrs.toml").exists());
656 }
657
658 #[test]
659 fn test_save_new_config() {
660 let temp = TempDir::new().unwrap();
661 let config = Config {
662 adr_dir: PathBuf::from("docs/decisions"),
663 mode: ConfigMode::NextGen,
664 default_status: None,
665 no_edit: false,
666 templates: TemplateConfig::default(),
667 generate: GenerateConfig::default(),
668 export: ExportConfig::default(),
669 };
670
671 config.save(temp.path()).unwrap();
672
673 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
674 assert!(content.contains("docs/decisions"));
675 assert!(content.contains("ng"));
676 assert!(!temp.path().join(".adr-dir").exists());
678 }
679
680 #[test]
681 fn test_save_new_config_with_templates() {
682 let temp = TempDir::new().unwrap();
683 let config = Config {
684 adr_dir: PathBuf::from("decisions"),
685 mode: ConfigMode::NextGen,
686 default_status: None,
687 no_edit: false,
688 templates: TemplateConfig {
689 format: Some("custom".to_string()),
690 variant: None,
691 custom: Some(PathBuf::from("my-template.md")),
692 },
693 generate: GenerateConfig::default(),
694 export: ExportConfig::default(),
695 };
696
697 config.save(temp.path()).unwrap();
698
699 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
700 assert!(content.contains("custom"));
701 assert!(content.contains("my-template.md"));
702 }
703
704 #[test]
705 fn test_save_and_load_roundtrip_compatible() {
706 let temp = TempDir::new().unwrap();
707 let original = Config {
708 adr_dir: PathBuf::from("architecture/decisions"),
709 mode: ConfigMode::Compatible,
710 default_status: None,
711 no_edit: false,
712 templates: TemplateConfig::default(),
713 generate: GenerateConfig::default(),
714 export: ExportConfig::default(),
715 };
716
717 original.save(temp.path()).unwrap();
718 let loaded = Config::load(temp.path()).unwrap();
719
720 assert_eq!(loaded.adr_dir, original.adr_dir);
721 assert_eq!(loaded.mode, ConfigMode::Compatible);
722 }
723
724 #[test]
725 fn test_save_and_load_roundtrip_nextgen() {
726 let temp = TempDir::new().unwrap();
727 let original = Config {
728 adr_dir: PathBuf::from("docs/adr"),
729 mode: ConfigMode::NextGen,
730 default_status: None,
731 no_edit: false,
732 templates: TemplateConfig {
733 format: Some("markdown".to_string()),
734 variant: None,
735 custom: None,
736 },
737 generate: GenerateConfig::default(),
738 export: ExportConfig::default(),
739 };
740
741 original.save(temp.path()).unwrap();
742 let loaded = Config::load(temp.path()).unwrap();
743
744 assert_eq!(loaded.adr_dir, original.adr_dir);
745 assert_eq!(loaded.mode, ConfigMode::NextGen);
746 assert_eq!(loaded.templates.format, Some("markdown".to_string()));
747 }
748
749 #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
752 #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
753 #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
754 fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
755 let config = Config {
756 adr_dir: PathBuf::from(adr_dir),
757 ..Default::default()
758 };
759 config.adr_path(Path::new(root))
760 }
761
762 #[test]
763 fn test_is_next_gen() {
764 let compatible = Config {
765 mode: ConfigMode::Compatible,
766 ..Default::default()
767 };
768 assert!(!compatible.is_next_gen());
769
770 let nextgen = Config {
771 mode: ConfigMode::NextGen,
772 ..Default::default()
773 };
774 assert!(nextgen.is_next_gen());
775 }
776
777 #[test]
780 fn test_config_mode_equality() {
781 assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
782 assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
783 assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
784 }
785
786 #[test]
787 fn test_config_mode_serialization_in_config() {
788 let config = Config {
790 mode: ConfigMode::Compatible,
791 ..Default::default()
792 };
793 let toml = toml::to_string(&config).unwrap();
794 assert!(toml.contains("mode = \"compatible\""));
795
796 let config = Config {
797 mode: ConfigMode::NextGen,
798 ..Default::default()
799 };
800 let toml = toml::to_string(&config).unwrap();
801 assert!(toml.contains("mode = \"ng\""));
802 }
803
804 #[test]
805 fn test_config_mode_deserialization_in_config() {
806 let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
807 assert_eq!(config.mode, ConfigMode::Compatible);
808
809 let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
810 assert_eq!(config.mode, ConfigMode::NextGen);
811 }
812
813 #[test]
814 fn test_config_mode_deserialization_nextgen_alias() {
815 let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
816 assert_eq!(config.mode, ConfigMode::NextGen);
817 }
818
819 #[test]
822 fn test_template_config_default() {
823 let config = TemplateConfig::default();
824 assert!(config.format.is_none());
825 assert!(config.variant.is_none());
826 assert!(config.custom.is_none());
827 }
828
829 #[test]
830 fn test_template_config_serialization() {
831 let config = TemplateConfig {
832 format: Some("nygard".to_string()),
833 variant: None,
834 custom: Some(PathBuf::from("templates/custom.md")),
835 };
836
837 let toml = toml::to_string(&config).unwrap();
838 assert!(toml.contains("nygard"));
839 assert!(toml.contains("templates/custom.md"));
840 }
841
842 #[test]
845 fn test_load_invalid_toml() {
846 let temp = TempDir::new().unwrap();
847 std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
848
849 let result = Config::load(temp.path());
850 assert!(result.is_err());
851 }
852
853 #[test]
854 fn test_load_empty_toml() {
855 let temp = TempDir::new().unwrap();
856 std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
857
858 let config = Config::load(temp.path()).unwrap();
860 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
861 }
862
863 #[test]
864 fn test_load_empty_adr_dir_file() {
865 let temp = TempDir::new().unwrap();
866 std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
867
868 let result = Config::load(temp.path());
869 assert!(result.is_err(), "Empty .adr-dir should produce an error");
870 }
871
872 #[test]
875 fn test_discover_finds_config_in_current_dir() {
876 let temp = TempDir::new().unwrap();
877 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
878
879 let discovered = discover(temp.path()).unwrap();
880 assert_eq!(discovered.root, temp.path());
881 assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
882 assert!(matches!(discovered.source, ConfigSource::Project(_)));
883 }
884
885 #[test]
886 fn test_discover_finds_config_in_parent_dir() {
887 let temp = TempDir::new().unwrap();
888 let subdir = temp.path().join("src").join("lib");
889 std::fs::create_dir_all(&subdir).unwrap();
890 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
891
892 let discovered = discover(&subdir).unwrap();
893 assert_eq!(discovered.root, temp.path());
894 assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
895 }
896
897 #[test]
898 fn test_discover_stops_at_git_root() {
899 let temp = TempDir::new().unwrap();
900
901 std::fs::create_dir(temp.path().join(".git")).unwrap();
903 let subdir = temp.path().join("src");
904 std::fs::create_dir(&subdir).unwrap();
905
906 let result = discover(&subdir);
910 assert!(result.is_ok());
912 let discovered = result.unwrap();
913 assert!(matches!(discovered.source, ConfigSource::Default));
914 }
915
916 #[test]
917 fn test_discover_prefers_adrs_toml_over_adr_dir() {
918 let temp = TempDir::new().unwrap();
919 std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
920 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
921
922 let discovered = discover(temp.path()).unwrap();
923 assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
924 }
925
926 #[test]
927 fn test_discover_finds_default_adr_dir() {
928 let temp = TempDir::new().unwrap();
929 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
930
931 let discovered = discover(temp.path()).unwrap();
932 assert_eq!(discovered.root, temp.path());
933 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
934 }
935
936 #[test]
937 fn test_discover_returns_defaults_when_nothing_found() {
938 let temp = TempDir::new().unwrap();
939 std::fs::create_dir(temp.path().join(".git")).unwrap();
941
942 let discovered = discover(temp.path()).unwrap();
943 assert!(matches!(discovered.source, ConfigSource::Default));
944 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
945 }
946
947 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
953
954 #[test]
955 fn test_apply_env_overrides() {
956 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
957 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
959 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
961
962 let mut config = Config::default();
963 apply_env_overrides(&mut config);
964
965 unsafe {
966 if let Some(v) = old {
967 std::env::set_var(ENV_ADR_DIRECTORY, v);
968 }
969 }
970
971 assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
973 }
974
975 #[test]
981 fn test_apply_env_overrides_sets_adr_dir() {
982 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
983 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
985
986 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
988 let mut config = Config::default();
989 apply_env_overrides(&mut config);
990
991 unsafe {
993 match old {
994 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
995 None => std::env::remove_var(ENV_ADR_DIRECTORY),
996 }
997 }
998
999 assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
1000 }
1001
1002 #[test]
1003 fn test_apply_env_overrides_overrides_non_default_adr_dir() {
1004 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1005 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1007
1008 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
1010 let mut config = Config {
1011 adr_dir: PathBuf::from("config_dir"),
1012 ..Default::default()
1013 };
1014 apply_env_overrides(&mut config);
1015
1016 unsafe {
1017 match old {
1018 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1019 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1020 }
1021 }
1022
1023 assert_eq!(config.adr_dir, PathBuf::from("env_override"));
1024 }
1025
1026 #[test]
1027 fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
1028 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1029 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1031
1032 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
1034
1035 let mut config = Config {
1036 adr_dir: PathBuf::from("original/path"),
1037 ..Default::default()
1038 };
1039 apply_env_overrides(&mut config);
1040
1041 unsafe {
1042 match old {
1043 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1044 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1045 }
1046 }
1047
1048 assert_eq!(config.adr_dir, PathBuf::from("original/path"));
1049 }
1050
1051 #[test]
1052 fn test_config_source_variants() {
1053 let project = ConfigSource::Project(PathBuf::from("test"));
1055 let global = ConfigSource::Global(PathBuf::from("test"));
1056 let env = ConfigSource::Environment;
1057 let default = ConfigSource::Default;
1058
1059 assert_ne!(project, global);
1060 assert_ne!(env, default);
1061 assert_eq!(default, ConfigSource::Default);
1062 }
1063
1064 #[test]
1065 fn test_config_merge() {
1066 let mut base = Config::default();
1067 let other = Config {
1068 adr_dir: PathBuf::from("custom"),
1069 mode: ConfigMode::NextGen,
1070 default_status: None,
1071 no_edit: false,
1072 templates: TemplateConfig {
1073 format: Some("madr".to_string()),
1074 variant: None,
1075 custom: None,
1076 },
1077 generate: GenerateConfig::default(),
1078 export: ExportConfig::default(),
1079 };
1080
1081 base.merge(&other);
1082 assert_eq!(base.adr_dir, PathBuf::from("custom"));
1083 assert_eq!(base.mode, ConfigMode::NextGen);
1084 assert_eq!(base.templates.format, Some("madr".to_string()));
1085 }
1086
1087 #[test]
1088 fn test_config_merge_preserves_default_adr_dir() {
1089 let mut base = Config {
1090 adr_dir: PathBuf::from("original"),
1091 ..Default::default()
1092 };
1093 let other = Config::default(); base.merge(&other);
1096 assert_eq!(base.adr_dir, PathBuf::from("original"));
1098 }
1099
1100 #[test]
1101 fn test_config_merge_default_status() {
1102 let mut base = Config::default();
1103 let other = Config {
1104 default_status: Some("accepted".to_string()),
1105 ..Default::default()
1106 };
1107
1108 base.merge(&other);
1109 assert_eq!(base.default_status, Some("accepted".to_string()));
1110 }
1111
1112 #[test]
1115 fn test_no_edit_defaults_to_false() {
1116 let config = Config::default();
1117 assert!(!config.no_edit);
1118 }
1119
1120 #[test]
1121 fn test_no_edit_true_from_toml() {
1122 let temp = TempDir::new().unwrap();
1123 std::fs::write(
1124 temp.path().join("adrs.toml"),
1125 "adr_dir = \"doc/adr\"\nno_edit = true\n",
1126 )
1127 .unwrap();
1128 let config = Config::load(temp.path()).unwrap();
1129 assert!(config.no_edit);
1130 }
1131
1132 #[test]
1133 fn test_no_edit_false_from_toml() {
1134 let temp = TempDir::new().unwrap();
1135 std::fs::write(
1136 temp.path().join("adrs.toml"),
1137 "adr_dir = \"doc/adr\"\nno_edit = false\n",
1138 )
1139 .unwrap();
1140 let config = Config::load(temp.path()).unwrap();
1141 assert!(!config.no_edit);
1142 }
1143
1144 #[test]
1145 fn test_no_edit_absent_from_toml_defaults_false() {
1146 let temp = TempDir::new().unwrap();
1147 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1148 let config = Config::load(temp.path()).unwrap();
1149 assert!(!config.no_edit);
1150 }
1151
1152 #[test]
1153 fn test_config_merge_no_edit() {
1154 let mut base = Config::default();
1155 let other = Config {
1156 no_edit: true,
1157 ..Default::default()
1158 };
1159 base.merge(&other);
1160 assert!(base.no_edit);
1161 }
1162
1163 #[test]
1164 fn test_config_merge_no_edit_false_does_not_overwrite_true() {
1165 let mut base = Config {
1167 no_edit: true,
1168 ..Default::default()
1169 };
1170 let other = Config::default(); base.merge(&other);
1172 assert!(
1173 base.no_edit,
1174 "merge with no_edit=false should not overwrite true"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_no_edit_save_load_roundtrip() {
1180 let temp = TempDir::new().unwrap();
1181 let original = Config {
1182 adr_dir: PathBuf::from("doc/adr"),
1183 mode: ConfigMode::NextGen,
1184 no_edit: true,
1185 ..Default::default()
1186 };
1187 original.save(temp.path()).unwrap();
1188 let loaded = Config::load(temp.path()).unwrap();
1189 assert!(loaded.no_edit);
1190 }
1191
1192 #[test]
1195 fn test_load_empty_adr_dir_in_toml() {
1196 let temp = TempDir::new().unwrap();
1197 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
1198
1199 let result = Config::load(temp.path());
1200 assert!(
1201 result.is_err(),
1202 "Empty adr_dir in TOML should produce an error"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_load_whitespace_only_adr_dir_file() {
1208 let temp = TempDir::new().unwrap();
1209 std::fs::write(temp.path().join(".adr-dir"), " \n ").unwrap();
1210
1211 let result = Config::load(temp.path());
1212 assert!(
1213 result.is_err(),
1214 "Whitespace-only .adr-dir should produce an error"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_invalid_format_string_accepted_in_toml() {
1220 let temp = TempDir::new().unwrap();
1224 std::fs::write(
1225 temp.path().join("adrs.toml"),
1226 r#"
1227adr_dir = "doc/adr"
1228
1229[templates]
1230format = "nonexistent"
1231"#,
1232 )
1233 .unwrap();
1234
1235 let config = Config::load(temp.path()).unwrap();
1236 assert_eq!(config.templates.format, Some("nonexistent".to_string()));
1237 }
1238
1239 #[test]
1240 fn test_invalid_variant_string_accepted_in_toml() {
1241 let temp = TempDir::new().unwrap();
1242 std::fs::write(
1243 temp.path().join("adrs.toml"),
1244 r#"
1245adr_dir = "doc/adr"
1246
1247[templates]
1248variant = "bogus"
1249"#,
1250 )
1251 .unwrap();
1252
1253 let config = Config::load(temp.path()).unwrap();
1254 assert_eq!(config.templates.variant, Some("bogus".to_string()));
1255 }
1256
1257 #[test]
1258 fn test_invalid_mode_string_rejected() {
1259 let temp = TempDir::new().unwrap();
1260 std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
1261
1262 let result = Config::load(temp.path());
1263 assert!(
1264 result.is_err(),
1265 "Invalid mode should produce a TOML parse error"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_unknown_toml_fields_accepted() {
1271 let temp = TempDir::new().unwrap();
1273 std::fs::write(
1274 temp.path().join("adrs.toml"),
1275 r#"
1276adr_dir = "doc/adr"
1277unknown_field = "hello"
1278
1279[templates]
1280also_unknown = true
1281"#,
1282 )
1283 .unwrap();
1284
1285 let config = Config::load(temp.path()).unwrap();
1286 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1287 }
1288
1289 #[test]
1290 fn test_custom_template_path_in_config() {
1291 let temp = TempDir::new().unwrap();
1292 std::fs::write(
1293 temp.path().join("adrs.toml"),
1294 r#"
1295adr_dir = "doc/adr"
1296mode = "ng"
1297
1298[templates]
1299custom = "templates/my-adr.md"
1300"#,
1301 )
1302 .unwrap();
1303
1304 let config = Config::load(temp.path()).unwrap();
1305 assert_eq!(
1306 config.templates.custom,
1307 Some(PathBuf::from("templates/my-adr.md"))
1308 );
1309 }
1310
1311 #[test]
1314 fn test_save_and_load_roundtrip_nextgen_with_templates() {
1315 let temp = TempDir::new().unwrap();
1316 let original = Config {
1317 adr_dir: PathBuf::from("docs/decisions"),
1318 mode: ConfigMode::NextGen,
1319 default_status: None,
1320 no_edit: false,
1321 templates: TemplateConfig {
1322 format: Some("madr".to_string()),
1323 variant: Some("minimal".to_string()),
1324 custom: Some(PathBuf::from("templates/custom.md")),
1325 },
1326 generate: GenerateConfig::default(),
1327 export: ExportConfig::default(),
1328 };
1329
1330 original.save(temp.path()).unwrap();
1331 let loaded = Config::load(temp.path()).unwrap();
1332
1333 assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1334 assert_eq!(loaded.mode, ConfigMode::NextGen);
1335 assert_eq!(loaded.templates.format, Some("madr".to_string()));
1336 assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1337 assert_eq!(
1338 loaded.templates.custom,
1339 Some(PathBuf::from("templates/custom.md"))
1340 );
1341 }
1342
1343 #[test]
1344 fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1345 let temp = TempDir::new().unwrap();
1347 let original = Config {
1348 mode: ConfigMode::NextGen,
1349 ..Default::default()
1350 };
1351
1352 original.save(temp.path()).unwrap();
1353
1354 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1356 assert!(content.contains(r#"mode = "ng""#));
1357
1358 let loaded = Config::load(temp.path()).unwrap();
1360 assert_eq!(loaded.mode, ConfigMode::NextGen);
1361 }
1362
1363 #[test]
1366 fn test_config_merge_variant_field() {
1367 let mut base = Config::default();
1368 let other = Config {
1369 templates: TemplateConfig {
1370 format: None,
1371 variant: Some("minimal".to_string()),
1372 custom: None,
1373 },
1374 ..Default::default()
1375 };
1376
1377 base.merge(&other);
1378 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1379 }
1380
1381 #[test]
1382 fn test_config_merge_custom_field() {
1383 let mut base = Config::default();
1384 let other = Config {
1385 templates: TemplateConfig {
1386 format: None,
1387 variant: None,
1388 custom: Some(PathBuf::from("my-template.md")),
1389 },
1390 ..Default::default()
1391 };
1392
1393 base.merge(&other);
1394 assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1395 }
1396
1397 #[test]
1398 fn test_config_merge_does_not_overwrite_with_none() {
1399 let mut base = Config {
1400 templates: TemplateConfig {
1401 format: Some("madr".to_string()),
1402 variant: Some("minimal".to_string()),
1403 custom: Some(PathBuf::from("template.md")),
1404 },
1405 ..Default::default()
1406 };
1407 let other = Config::default(); base.merge(&other);
1410
1411 assert_eq!(base.templates.format, Some("madr".to_string()));
1413 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1414 assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1415 }
1416
1417 #[test]
1420 fn test_generate_config_default() {
1421 let config = GenerateConfig::default();
1422 assert!(config.toc_prefix.is_none());
1423 }
1424
1425 #[test]
1426 fn test_generate_toc_prefix_from_toml() {
1427 let temp = TempDir::new().unwrap();
1428 std::fs::write(
1429 temp.path().join("adrs.toml"),
1430 r#"
1431adr_dir = "doc/adr"
1432
1433[generate]
1434toc_prefix = "./"
1435"#,
1436 )
1437 .unwrap();
1438
1439 let config = Config::load(temp.path()).unwrap();
1440 assert_eq!(config.generate.toc_prefix, Some("./".to_string()));
1441 }
1442
1443 #[test]
1444 fn test_generate_toc_prefix_absent_defaults_none() {
1445 let temp = TempDir::new().unwrap();
1446 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1447
1448 let config = Config::load(temp.path()).unwrap();
1449 assert!(config.generate.toc_prefix.is_none());
1450 }
1451
1452 #[test]
1453 fn test_config_merge_generate_toc_prefix() {
1454 let mut base = Config::default();
1455 let other = Config {
1456 generate: GenerateConfig {
1457 toc_prefix: Some("./".to_string()),
1458 },
1459 ..Default::default()
1460 };
1461
1462 base.merge(&other);
1463 assert_eq!(base.generate.toc_prefix, Some("./".to_string()));
1464 }
1465
1466 #[test]
1467 fn test_config_merge_generate_toc_prefix_none_does_not_overwrite() {
1468 let mut base = Config {
1469 generate: GenerateConfig {
1470 toc_prefix: Some("docs/".to_string()),
1471 },
1472 ..Default::default()
1473 };
1474 let other = Config::default(); base.merge(&other);
1477 assert_eq!(
1478 base.generate.toc_prefix,
1479 Some("docs/".to_string()),
1480 "merge with toc_prefix=None should not overwrite existing value"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_generate_toc_prefix_save_load_roundtrip() {
1486 let temp = TempDir::new().unwrap();
1487 let original = Config {
1488 mode: ConfigMode::NextGen,
1489 generate: GenerateConfig {
1490 toc_prefix: Some("wiki/adr/".to_string()),
1491 },
1492 ..Default::default()
1493 };
1494
1495 original.save(temp.path()).unwrap();
1496 let loaded = Config::load(temp.path()).unwrap();
1497
1498 assert_eq!(
1499 loaded.generate.toc_prefix,
1500 Some("wiki/adr/".to_string()),
1501 "toc_prefix should survive a save/load round-trip"
1502 );
1503 }
1504
1505 #[test]
1508 fn test_export_config_default() {
1509 let config = ExportConfig::default();
1510 assert!(config.base_url.is_none());
1511 }
1512
1513 #[test]
1514 fn test_export_base_url_from_toml() {
1515 let temp = TempDir::new().unwrap();
1516 std::fs::write(
1517 temp.path().join("adrs.toml"),
1518 r#"
1519adr_dir = "doc/adr"
1520
1521[export]
1522base_url = "https://github.com/org/repo/blob/main/doc/adr"
1523"#,
1524 )
1525 .unwrap();
1526
1527 let config = Config::load(temp.path()).unwrap();
1528 assert_eq!(
1529 config.export.base_url,
1530 Some("https://github.com/org/repo/blob/main/doc/adr".to_string())
1531 );
1532 }
1533
1534 #[test]
1535 fn test_export_base_url_absent_defaults_none() {
1536 let temp = TempDir::new().unwrap();
1537 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1538
1539 let config = Config::load(temp.path()).unwrap();
1540 assert!(config.export.base_url.is_none());
1541 }
1542
1543 #[test]
1544 fn test_config_merge_export_base_url() {
1545 let mut base = Config::default();
1546 let other = Config {
1547 export: ExportConfig {
1548 base_url: Some("https://example.com/adr".to_string()),
1549 },
1550 ..Default::default()
1551 };
1552
1553 base.merge(&other);
1554 assert_eq!(
1555 base.export.base_url,
1556 Some("https://example.com/adr".to_string())
1557 );
1558 }
1559
1560 #[test]
1561 fn test_config_merge_export_base_url_none_does_not_overwrite() {
1562 let mut base = Config {
1563 export: ExportConfig {
1564 base_url: Some("https://existing.example.com/adr".to_string()),
1565 },
1566 ..Default::default()
1567 };
1568 let other = Config::default(); base.merge(&other);
1571 assert_eq!(
1572 base.export.base_url,
1573 Some("https://existing.example.com/adr".to_string()),
1574 "merge with export.base_url=None should not overwrite existing value"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_export_base_url_save_load_roundtrip() {
1580 let temp = TempDir::new().unwrap();
1581 let original = Config {
1582 mode: ConfigMode::NextGen,
1583 export: ExportConfig {
1584 base_url: Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1585 },
1586 ..Default::default()
1587 };
1588
1589 original.save(temp.path()).unwrap();
1590 let loaded = Config::load(temp.path()).unwrap();
1591
1592 assert_eq!(
1593 loaded.export.base_url,
1594 Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1595 "export.base_url should survive a save/load round-trip"
1596 );
1597 }
1598}