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 #[serde(default)]
55 pub doctor: DoctorConfig,
56}
57
58impl Default for Config {
59 fn default() -> Self {
60 Self {
61 adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
62 mode: ConfigMode::Compatible,
63 default_status: None,
64 no_edit: false,
65 templates: TemplateConfig::default(),
66 generate: GenerateConfig::default(),
67 export: ExportConfig::default(),
68 doctor: DoctorConfig::default(),
69 }
70 }
71}
72
73fn deserialize_config(content: &str) -> Result<(Config, Vec<String>)> {
82 let mut unknown_keys = Vec::new();
83 let deserializer = toml::Deserializer::new(content);
84 let config: Config = serde_ignored::deserialize(deserializer, |path| {
85 unknown_keys.push(path.to_string());
86 })?;
87 unknown_keys.sort();
88 unknown_keys.dedup();
89 Ok((config, unknown_keys))
90}
91
92impl Config {
93 pub fn load(root: &Path) -> Result<Self> {
100 let config_path = root.join(CONFIG_FILE);
102 if config_path.exists() {
103 let content = std::fs::read_to_string(&config_path)?;
104 let (config, _unknown_keys) = deserialize_config(&content)?;
105 if config.adr_dir.as_os_str().is_empty() {
106 return Err(Error::ConfigError(
107 "adr_dir cannot be empty in adrs.toml".into(),
108 ));
109 }
110 return Ok(config);
111 }
112
113 let legacy_path = root.join(LEGACY_CONFIG_FILE);
115 if legacy_path.exists() {
116 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
117 if adr_dir.is_empty() {
118 return Err(Error::ConfigError(
119 "ADR directory path is empty in .adr-dir file".into(),
120 ));
121 }
122 return Ok(Self {
123 adr_dir: PathBuf::from(adr_dir),
124 mode: ConfigMode::Compatible,
125 default_status: None,
126 no_edit: false,
127 templates: TemplateConfig::default(),
128 generate: GenerateConfig::default(),
129 export: ExportConfig::default(),
130 doctor: DoctorConfig::default(),
131 });
132 }
133
134 let default_dir = root.join(DEFAULT_ADR_DIR);
136 if default_dir.exists() {
137 return Ok(Self::default());
138 }
139
140 Err(Error::AdrDirNotFound)
141 }
142
143 pub fn load_or_default(root: &Path) -> Self {
145 Self::load(root).unwrap_or_default()
146 }
147
148 pub fn save(&self, root: &Path) -> Result<()> {
150 match self.mode {
151 ConfigMode::Compatible => {
152 let path = root.join(LEGACY_CONFIG_FILE);
154 std::fs::write(&path, self.adr_dir.display().to_string())?;
155 }
156 ConfigMode::NextGen => {
157 let path = root.join(CONFIG_FILE);
159 let content =
160 toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
161 std::fs::write(&path, content)?;
162 }
163 }
164 Ok(())
165 }
166
167 pub fn adr_path(&self, root: &Path) -> PathBuf {
169 root.join(&self.adr_dir)
170 }
171
172 pub fn is_next_gen(&self) -> bool {
174 matches!(self.mode, ConfigMode::NextGen)
175 }
176
177 pub fn merge(&mut self, other: &Config) {
179 if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
181 self.adr_dir = other.adr_dir.clone();
182 }
183 self.mode = other.mode;
185 if other.templates.format.is_some() {
187 self.templates.format = other.templates.format.clone();
188 }
189 if other.templates.variant.is_some() {
190 self.templates.variant = other.templates.variant.clone();
191 }
192 if other.templates.custom.is_some() {
193 self.templates.custom = other.templates.custom.clone();
194 }
195 if other.default_status.is_some() {
196 self.default_status = other.default_status.clone();
197 }
198 if other.no_edit {
199 self.no_edit = other.no_edit;
200 }
201 if other.generate.toc_prefix.is_some() {
202 self.generate.toc_prefix = other.generate.toc_prefix.clone();
203 }
204 if other.export.base_url.is_some() {
205 self.export.base_url = other.export.base_url.clone();
206 }
207 if !other.doctor.ignore.is_empty() {
208 self.doctor.ignore = other.doctor.ignore.clone();
209 }
210 if other.doctor.warnings_as_errors {
211 self.doctor.warnings_as_errors = other.doctor.warnings_as_errors;
212 }
213 if !other.doctor.ignore_path.is_empty() {
214 self.doctor.ignore_path = other.doctor.ignore_path.clone();
215 }
216 }
217}
218
219#[derive(Debug, Clone)]
221pub struct DiscoveredConfig {
222 pub config: Config,
224 pub root: PathBuf,
226 pub source: ConfigSource,
228 pub unknown_keys: Vec<String>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum ConfigSource {
237 Project(PathBuf),
239 Global(PathBuf),
241 Environment,
243 Default,
245}
246
247pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
257 if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
259 let path = PathBuf::from(&config_path);
260 if path.exists() {
261 let content = std::fs::read_to_string(&path)?;
262 let (mut config, unknown_keys) = deserialize_config(&content)?;
263 apply_env_overrides(&mut config);
264 return Ok(DiscoveredConfig {
265 config,
266 root: path
267 .parent()
268 .map(|p| p.to_path_buf())
269 .unwrap_or_else(|| start_dir.to_path_buf()),
270 source: ConfigSource::Environment,
271 unknown_keys,
272 });
273 }
274 }
275
276 if let Some((root, config, source, unknown_keys)) = search_upward(start_dir)? {
278 let mut config = config;
279 apply_env_overrides(&mut config);
280 return Ok(DiscoveredConfig {
281 config,
282 root,
283 source,
284 unknown_keys,
285 });
286 }
287
288 if let Some((config, path, unknown_keys)) = load_global_config()? {
290 let mut config = config;
291 apply_env_overrides(&mut config);
292 return Ok(DiscoveredConfig {
293 config,
294 root: start_dir.to_path_buf(),
295 source: ConfigSource::Global(path),
296 unknown_keys,
297 });
298 }
299
300 let mut config = Config::default();
302 apply_env_overrides(&mut config);
303 Ok(DiscoveredConfig {
304 config,
305 root: start_dir.to_path_buf(),
306 source: ConfigSource::Default,
307 unknown_keys: Vec::new(),
308 })
309}
310
311#[allow(clippy::type_complexity)]
313fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource, Vec<String>)>> {
314 let mut current = start_dir.to_path_buf();
315
316 loop {
317 let config_path = current.join(CONFIG_FILE);
319 if config_path.exists() {
320 let content = std::fs::read_to_string(&config_path)?;
321 let (config, unknown_keys) = deserialize_config(&content)?;
322 return Ok(Some((
323 current,
324 config,
325 ConfigSource::Project(config_path),
326 unknown_keys,
327 )));
328 }
329
330 let legacy_path = current.join(LEGACY_CONFIG_FILE);
332 if legacy_path.exists() {
333 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
334 let config = Config {
335 adr_dir: PathBuf::from(adr_dir),
336 mode: ConfigMode::Compatible,
337 default_status: None,
338 no_edit: false,
339 templates: TemplateConfig::default(),
340 generate: GenerateConfig::default(),
341 export: ExportConfig::default(),
342 doctor: DoctorConfig::default(),
343 };
344 return Ok(Some((
345 current,
346 config,
347 ConfigSource::Project(legacy_path),
348 Vec::new(),
349 )));
350 }
351
352 let default_dir = current.join(DEFAULT_ADR_DIR);
354 if default_dir.exists() {
355 return Ok(Some((
356 current,
357 Config::default(),
358 ConfigSource::Default,
359 Vec::new(),
360 )));
361 }
362
363 if current.join(".git").exists() {
365 break;
366 }
367
368 match current.parent() {
370 Some(parent) => current = parent.to_path_buf(),
371 None => break,
372 }
373 }
374
375 Ok(None)
376}
377
378fn load_global_config() -> Result<Option<(Config, PathBuf, Vec<String>)>> {
380 let config_dir = dirs_config_dir()?;
381 let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
382
383 if global_path.exists() {
384 let content = std::fs::read_to_string(&global_path)?;
385 let (config, unknown_keys) = deserialize_config(&content)?;
386 return Ok(Some((config, global_path, unknown_keys)));
387 }
388
389 Ok(None)
390}
391
392fn dirs_config_dir() -> Result<PathBuf> {
394 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
396 return Ok(PathBuf::from(xdg));
397 }
398
399 if let Ok(home) = std::env::var("HOME") {
400 return Ok(PathBuf::from(home).join(".config"));
401 }
402
403 if let Ok(appdata) = std::env::var("APPDATA") {
405 return Ok(PathBuf::from(appdata));
406 }
407
408 Err(Error::ConfigError(
409 "Could not determine config directory".into(),
410 ))
411}
412
413fn apply_env_overrides(config: &mut Config) {
415 if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
416 config.adr_dir = PathBuf::from(adr_dir);
417 }
418}
419
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "lowercase")]
423pub enum ConfigMode {
424 #[default]
426 Compatible,
427
428 #[serde(rename = "ng", alias = "nextgen")]
430 NextGen,
431}
432
433#[derive(Debug, Clone, Default, Serialize, Deserialize)]
435#[serde(default)]
436pub struct TemplateConfig {
437 pub format: Option<String>,
439
440 pub variant: Option<String>,
442
443 pub custom: Option<PathBuf>,
445}
446
447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
449#[serde(default)]
450pub struct GenerateConfig {
451 pub toc_prefix: Option<String>,
453}
454
455#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457#[serde(default)]
458pub struct ExportConfig {
459 pub base_url: Option<String>,
461}
462
463#[derive(Debug, Clone, Default, Serialize, Deserialize)]
465#[serde(default)]
466pub struct DoctorConfig {
467 pub ignore: Vec<String>,
471
472 pub warnings_as_errors: bool,
474
475 pub ignore_path: Vec<DoctorIgnorePath>,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
494#[serde(default)]
495pub struct DoctorIgnorePath {
496 pub glob: String,
499
500 pub rules: Vec<String>,
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use tempfile::TempDir;
508 use test_case::test_case;
509
510 #[test]
513 fn test_default_config() {
514 let config = Config::default();
515 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
516 assert_eq!(config.mode, ConfigMode::Compatible);
517 assert!(config.default_status.is_none());
518 assert!(config.templates.format.is_none());
519 assert!(config.templates.custom.is_none());
520 }
521
522 #[test]
523 fn test_constants() {
524 assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
525 assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
526 assert_eq!(CONFIG_FILE, "adrs.toml");
527 }
528
529 #[test]
530 fn test_config_mode_default() {
531 assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
532 }
533
534 #[test]
537 fn test_load_legacy_config() {
538 let temp = TempDir::new().unwrap();
539 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
540
541 let config = Config::load(temp.path()).unwrap();
542 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
543 assert_eq!(config.mode, ConfigMode::Compatible);
544 }
545
546 #[test]
547 fn test_load_legacy_config_with_whitespace() {
548 let temp = TempDir::new().unwrap();
549 std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
550
551 let config = Config::load(temp.path()).unwrap();
552 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
553 }
554
555 #[test]
556 fn test_load_legacy_config_nested_path() {
557 let temp = TempDir::new().unwrap();
558 std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
559
560 let config = Config::load(temp.path()).unwrap();
561 assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
562 }
563
564 #[test]
565 fn test_load_new_config() {
566 let temp = TempDir::new().unwrap();
567 std::fs::write(
568 temp.path().join("adrs.toml"),
569 r#"
570adr_dir = "docs/decisions"
571mode = "ng"
572"#,
573 )
574 .unwrap();
575
576 let config = Config::load(temp.path()).unwrap();
577 assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
578 assert_eq!(config.mode, ConfigMode::NextGen);
579 }
580
581 #[test]
582 fn test_load_new_config_compatible_mode() {
583 let temp = TempDir::new().unwrap();
584 std::fs::write(
585 temp.path().join("adrs.toml"),
586 r#"
587adr_dir = "doc/adr"
588mode = "compatible"
589"#,
590 )
591 .unwrap();
592
593 let config = Config::load(temp.path()).unwrap();
594 assert_eq!(config.mode, ConfigMode::Compatible);
595 }
596
597 #[test]
598 fn test_load_new_config_with_default_status() {
599 let temp = TempDir::new().unwrap();
600 std::fs::write(
601 temp.path().join("adrs.toml"),
602 r#"
603adr_dir = "doc/adr"
604default_status = "accepted"
605"#,
606 )
607 .unwrap();
608
609 let config = Config::load(temp.path()).unwrap();
610 assert_eq!(config.default_status, Some("accepted".to_string()));
611 }
612
613 #[test]
614 fn test_load_new_config_with_templates() {
615 let temp = TempDir::new().unwrap();
616 std::fs::write(
617 temp.path().join("adrs.toml"),
618 r#"
619adr_dir = "decisions"
620mode = "ng"
621
622[templates]
623format = "markdown"
624custom = "templates/adr.md"
625"#,
626 )
627 .unwrap();
628
629 let config = Config::load(temp.path()).unwrap();
630 assert_eq!(config.templates.format, Some("markdown".to_string()));
631 assert_eq!(
632 config.templates.custom,
633 Some(PathBuf::from("templates/adr.md"))
634 );
635 }
636
637 #[test]
638 fn test_load_new_config_with_template_variant() {
639 let temp = TempDir::new().unwrap();
640 std::fs::write(
641 temp.path().join("adrs.toml"),
642 r#"
643adr_dir = "decisions"
644mode = "ng"
645
646[templates]
647format = "madr"
648variant = "minimal"
649"#,
650 )
651 .unwrap();
652
653 let config = Config::load(temp.path()).unwrap();
654 assert_eq!(config.templates.format, Some("madr".to_string()));
655 assert_eq!(config.templates.variant, Some("minimal".to_string()));
656 }
657
658 #[test]
659 fn test_load_new_config_with_nextgen_alias() {
660 let temp = TempDir::new().unwrap();
661 std::fs::write(
662 temp.path().join("adrs.toml"),
663 r#"
664adr_dir = "decisions"
665mode = "nextgen"
666"#,
667 )
668 .unwrap();
669
670 let config = Config::load(temp.path()).unwrap();
671 assert_eq!(config.mode, ConfigMode::NextGen);
672 }
673
674 #[test]
675 fn test_load_new_config_minimal() {
676 let temp = TempDir::new().unwrap();
677 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
678
679 let config = Config::load(temp.path()).unwrap();
680 assert_eq!(config.adr_dir, PathBuf::from("adrs"));
681 assert_eq!(config.mode, ConfigMode::Compatible);
683 }
684
685 #[test]
686 fn test_load_prefers_new_config_over_legacy() {
687 let temp = TempDir::new().unwrap();
688 std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
690 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
691
692 let config = Config::load(temp.path()).unwrap();
693 assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
695 }
696
697 #[test]
698 fn test_load_default_dir_exists() {
699 let temp = TempDir::new().unwrap();
700 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
702
703 let config = Config::load(temp.path()).unwrap();
704 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
705 }
706
707 #[test]
708 fn test_load_no_config_no_default_dir() {
709 let temp = TempDir::new().unwrap();
710 let result = Config::load(temp.path());
713 assert!(result.is_err());
714 }
715
716 #[test]
717 fn test_load_or_default_returns_default_on_error() {
718 let temp = TempDir::new().unwrap();
719 let config = Config::load_or_default(temp.path());
722 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
723 assert_eq!(config.mode, ConfigMode::Compatible);
724 }
725
726 #[test]
727 fn test_load_or_default_returns_config_when_exists() {
728 let temp = TempDir::new().unwrap();
729 std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
730
731 let config = Config::load_or_default(temp.path());
732 assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
733 }
734
735 #[test]
738 fn test_save_legacy_config() {
739 let temp = TempDir::new().unwrap();
740 let config = Config {
741 adr_dir: PathBuf::from("my/adrs"),
742 mode: ConfigMode::Compatible,
743 default_status: None,
744 no_edit: false,
745 templates: TemplateConfig::default(),
746 generate: GenerateConfig::default(),
747 export: ExportConfig::default(),
748 doctor: DoctorConfig::default(),
749 };
750
751 config.save(temp.path()).unwrap();
752
753 let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
754 assert_eq!(content, "my/adrs");
755 assert!(!temp.path().join("adrs.toml").exists());
757 }
758
759 #[test]
760 fn test_save_new_config() {
761 let temp = TempDir::new().unwrap();
762 let config = Config {
763 adr_dir: PathBuf::from("docs/decisions"),
764 mode: ConfigMode::NextGen,
765 default_status: None,
766 no_edit: false,
767 templates: TemplateConfig::default(),
768 generate: GenerateConfig::default(),
769 export: ExportConfig::default(),
770 doctor: DoctorConfig::default(),
771 };
772
773 config.save(temp.path()).unwrap();
774
775 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
776 assert!(content.contains("docs/decisions"));
777 assert!(content.contains("ng"));
778 assert!(!temp.path().join(".adr-dir").exists());
780 }
781
782 #[test]
783 fn test_save_new_config_with_templates() {
784 let temp = TempDir::new().unwrap();
785 let config = Config {
786 adr_dir: PathBuf::from("decisions"),
787 mode: ConfigMode::NextGen,
788 default_status: None,
789 no_edit: false,
790 templates: TemplateConfig {
791 format: Some("custom".to_string()),
792 variant: None,
793 custom: Some(PathBuf::from("my-template.md")),
794 },
795 generate: GenerateConfig::default(),
796 export: ExportConfig::default(),
797 doctor: DoctorConfig::default(),
798 };
799
800 config.save(temp.path()).unwrap();
801
802 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
803 assert!(content.contains("custom"));
804 assert!(content.contains("my-template.md"));
805 }
806
807 #[test]
808 fn test_save_and_load_roundtrip_compatible() {
809 let temp = TempDir::new().unwrap();
810 let original = Config {
811 adr_dir: PathBuf::from("architecture/decisions"),
812 mode: ConfigMode::Compatible,
813 default_status: None,
814 no_edit: false,
815 templates: TemplateConfig::default(),
816 generate: GenerateConfig::default(),
817 export: ExportConfig::default(),
818 doctor: DoctorConfig::default(),
819 };
820
821 original.save(temp.path()).unwrap();
822 let loaded = Config::load(temp.path()).unwrap();
823
824 assert_eq!(loaded.adr_dir, original.adr_dir);
825 assert_eq!(loaded.mode, ConfigMode::Compatible);
826 }
827
828 #[test]
829 fn test_save_and_load_roundtrip_nextgen() {
830 let temp = TempDir::new().unwrap();
831 let original = Config {
832 adr_dir: PathBuf::from("docs/adr"),
833 mode: ConfigMode::NextGen,
834 default_status: None,
835 no_edit: false,
836 templates: TemplateConfig {
837 format: Some("markdown".to_string()),
838 variant: None,
839 custom: None,
840 },
841 generate: GenerateConfig::default(),
842 export: ExportConfig::default(),
843 doctor: DoctorConfig::default(),
844 };
845
846 original.save(temp.path()).unwrap();
847 let loaded = Config::load(temp.path()).unwrap();
848
849 assert_eq!(loaded.adr_dir, original.adr_dir);
850 assert_eq!(loaded.mode, ConfigMode::NextGen);
851 assert_eq!(loaded.templates.format, Some("markdown".to_string()));
852 }
853
854 #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
857 #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
858 #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
859 fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
860 let config = Config {
861 adr_dir: PathBuf::from(adr_dir),
862 ..Default::default()
863 };
864 config.adr_path(Path::new(root))
865 }
866
867 #[test]
868 fn test_is_next_gen() {
869 let compatible = Config {
870 mode: ConfigMode::Compatible,
871 ..Default::default()
872 };
873 assert!(!compatible.is_next_gen());
874
875 let nextgen = Config {
876 mode: ConfigMode::NextGen,
877 ..Default::default()
878 };
879 assert!(nextgen.is_next_gen());
880 }
881
882 #[test]
885 fn test_config_mode_equality() {
886 assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
887 assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
888 assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
889 }
890
891 #[test]
892 fn test_config_mode_serialization_in_config() {
893 let config = Config {
895 mode: ConfigMode::Compatible,
896 ..Default::default()
897 };
898 let toml = toml::to_string(&config).unwrap();
899 assert!(toml.contains("mode = \"compatible\""));
900
901 let config = Config {
902 mode: ConfigMode::NextGen,
903 ..Default::default()
904 };
905 let toml = toml::to_string(&config).unwrap();
906 assert!(toml.contains("mode = \"ng\""));
907 }
908
909 #[test]
910 fn test_config_mode_deserialization_in_config() {
911 let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
912 assert_eq!(config.mode, ConfigMode::Compatible);
913
914 let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
915 assert_eq!(config.mode, ConfigMode::NextGen);
916 }
917
918 #[test]
919 fn test_config_mode_deserialization_nextgen_alias() {
920 let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
921 assert_eq!(config.mode, ConfigMode::NextGen);
922 }
923
924 #[test]
927 fn test_template_config_default() {
928 let config = TemplateConfig::default();
929 assert!(config.format.is_none());
930 assert!(config.variant.is_none());
931 assert!(config.custom.is_none());
932 }
933
934 #[test]
935 fn test_template_config_serialization() {
936 let config = TemplateConfig {
937 format: Some("nygard".to_string()),
938 variant: None,
939 custom: Some(PathBuf::from("templates/custom.md")),
940 };
941
942 let toml = toml::to_string(&config).unwrap();
943 assert!(toml.contains("nygard"));
944 assert!(toml.contains("templates/custom.md"));
945 }
946
947 #[test]
950 fn test_load_invalid_toml() {
951 let temp = TempDir::new().unwrap();
952 std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
953
954 let result = Config::load(temp.path());
955 assert!(result.is_err());
956 }
957
958 #[test]
959 fn test_load_empty_toml() {
960 let temp = TempDir::new().unwrap();
961 std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
962
963 let config = Config::load(temp.path()).unwrap();
965 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
966 }
967
968 #[test]
969 fn test_load_empty_adr_dir_file() {
970 let temp = TempDir::new().unwrap();
971 std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
972
973 let result = Config::load(temp.path());
974 assert!(result.is_err(), "Empty .adr-dir should produce an error");
975 }
976
977 #[test]
980 fn test_discover_finds_config_in_current_dir() {
981 let temp = TempDir::new().unwrap();
982 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
983
984 let discovered = discover(temp.path()).unwrap();
985 assert_eq!(discovered.root, temp.path());
986 assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
987 assert!(matches!(discovered.source, ConfigSource::Project(_)));
988 }
989
990 #[test]
991 fn test_discover_finds_config_in_parent_dir() {
992 let temp = TempDir::new().unwrap();
993 let subdir = temp.path().join("src").join("lib");
994 std::fs::create_dir_all(&subdir).unwrap();
995 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
996
997 let discovered = discover(&subdir).unwrap();
998 assert_eq!(discovered.root, temp.path());
999 assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
1000 }
1001
1002 #[test]
1003 fn test_discover_stops_at_git_root() {
1004 let temp = TempDir::new().unwrap();
1005
1006 std::fs::create_dir(temp.path().join(".git")).unwrap();
1008 let subdir = temp.path().join("src");
1009 std::fs::create_dir(&subdir).unwrap();
1010
1011 let result = discover(&subdir);
1015 assert!(result.is_ok());
1017 let discovered = result.unwrap();
1018 assert!(matches!(discovered.source, ConfigSource::Default));
1019 }
1020
1021 #[test]
1022 fn test_discover_prefers_adrs_toml_over_adr_dir() {
1023 let temp = TempDir::new().unwrap();
1024 std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
1025 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
1026
1027 let discovered = discover(temp.path()).unwrap();
1028 assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
1029 }
1030
1031 #[test]
1032 fn test_discover_finds_default_adr_dir() {
1033 let temp = TempDir::new().unwrap();
1034 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
1035
1036 let discovered = discover(temp.path()).unwrap();
1037 assert_eq!(discovered.root, temp.path());
1038 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
1039 }
1040
1041 #[test]
1042 fn test_discover_returns_defaults_when_nothing_found() {
1043 let temp = TempDir::new().unwrap();
1044 std::fs::create_dir(temp.path().join(".git")).unwrap();
1046
1047 let discovered = discover(temp.path()).unwrap();
1048 assert!(matches!(discovered.source, ConfigSource::Default));
1049 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
1050 }
1051
1052 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1058
1059 #[test]
1060 fn test_apply_env_overrides() {
1061 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1062 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1064 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
1066
1067 let mut config = Config::default();
1068 apply_env_overrides(&mut config);
1069
1070 unsafe {
1071 if let Some(v) = old {
1072 std::env::set_var(ENV_ADR_DIRECTORY, v);
1073 }
1074 }
1075
1076 assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
1078 }
1079
1080 #[test]
1086 fn test_apply_env_overrides_sets_adr_dir() {
1087 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1088 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1090
1091 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
1093 let mut config = Config::default();
1094 apply_env_overrides(&mut config);
1095
1096 unsafe {
1098 match old {
1099 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1100 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1101 }
1102 }
1103
1104 assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
1105 }
1106
1107 #[test]
1108 fn test_apply_env_overrides_overrides_non_default_adr_dir() {
1109 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1110 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1112
1113 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
1115 let mut config = Config {
1116 adr_dir: PathBuf::from("config_dir"),
1117 ..Default::default()
1118 };
1119 apply_env_overrides(&mut config);
1120
1121 unsafe {
1122 match old {
1123 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1124 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1125 }
1126 }
1127
1128 assert_eq!(config.adr_dir, PathBuf::from("env_override"));
1129 }
1130
1131 #[test]
1132 fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
1133 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1134 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1136
1137 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
1139
1140 let mut config = Config {
1141 adr_dir: PathBuf::from("original/path"),
1142 ..Default::default()
1143 };
1144 apply_env_overrides(&mut config);
1145
1146 unsafe {
1147 match old {
1148 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1149 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1150 }
1151 }
1152
1153 assert_eq!(config.adr_dir, PathBuf::from("original/path"));
1154 }
1155
1156 #[test]
1157 fn test_config_source_variants() {
1158 let project = ConfigSource::Project(PathBuf::from("test"));
1160 let global = ConfigSource::Global(PathBuf::from("test"));
1161 let env = ConfigSource::Environment;
1162 let default = ConfigSource::Default;
1163
1164 assert_ne!(project, global);
1165 assert_ne!(env, default);
1166 assert_eq!(default, ConfigSource::Default);
1167 }
1168
1169 #[test]
1170 fn test_config_merge() {
1171 let mut base = Config::default();
1172 let other = Config {
1173 adr_dir: PathBuf::from("custom"),
1174 mode: ConfigMode::NextGen,
1175 default_status: None,
1176 no_edit: false,
1177 templates: TemplateConfig {
1178 format: Some("madr".to_string()),
1179 variant: None,
1180 custom: None,
1181 },
1182 generate: GenerateConfig::default(),
1183 export: ExportConfig::default(),
1184 doctor: DoctorConfig::default(),
1185 };
1186
1187 base.merge(&other);
1188 assert_eq!(base.adr_dir, PathBuf::from("custom"));
1189 assert_eq!(base.mode, ConfigMode::NextGen);
1190 assert_eq!(base.templates.format, Some("madr".to_string()));
1191 }
1192
1193 #[test]
1194 fn test_config_merge_preserves_default_adr_dir() {
1195 let mut base = Config {
1196 adr_dir: PathBuf::from("original"),
1197 ..Default::default()
1198 };
1199 let other = Config::default(); base.merge(&other);
1202 assert_eq!(base.adr_dir, PathBuf::from("original"));
1204 }
1205
1206 #[test]
1207 fn test_config_merge_default_status() {
1208 let mut base = Config::default();
1209 let other = Config {
1210 default_status: Some("accepted".to_string()),
1211 ..Default::default()
1212 };
1213
1214 base.merge(&other);
1215 assert_eq!(base.default_status, Some("accepted".to_string()));
1216 }
1217
1218 #[test]
1221 fn test_no_edit_defaults_to_false() {
1222 let config = Config::default();
1223 assert!(!config.no_edit);
1224 }
1225
1226 #[test]
1227 fn test_no_edit_true_from_toml() {
1228 let temp = TempDir::new().unwrap();
1229 std::fs::write(
1230 temp.path().join("adrs.toml"),
1231 "adr_dir = \"doc/adr\"\nno_edit = true\n",
1232 )
1233 .unwrap();
1234 let config = Config::load(temp.path()).unwrap();
1235 assert!(config.no_edit);
1236 }
1237
1238 #[test]
1239 fn test_no_edit_false_from_toml() {
1240 let temp = TempDir::new().unwrap();
1241 std::fs::write(
1242 temp.path().join("adrs.toml"),
1243 "adr_dir = \"doc/adr\"\nno_edit = false\n",
1244 )
1245 .unwrap();
1246 let config = Config::load(temp.path()).unwrap();
1247 assert!(!config.no_edit);
1248 }
1249
1250 #[test]
1251 fn test_no_edit_absent_from_toml_defaults_false() {
1252 let temp = TempDir::new().unwrap();
1253 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1254 let config = Config::load(temp.path()).unwrap();
1255 assert!(!config.no_edit);
1256 }
1257
1258 #[test]
1259 fn test_config_merge_no_edit() {
1260 let mut base = Config::default();
1261 let other = Config {
1262 no_edit: true,
1263 ..Default::default()
1264 };
1265 base.merge(&other);
1266 assert!(base.no_edit);
1267 }
1268
1269 #[test]
1270 fn test_config_merge_no_edit_false_does_not_overwrite_true() {
1271 let mut base = Config {
1273 no_edit: true,
1274 ..Default::default()
1275 };
1276 let other = Config::default(); base.merge(&other);
1278 assert!(
1279 base.no_edit,
1280 "merge with no_edit=false should not overwrite true"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_no_edit_save_load_roundtrip() {
1286 let temp = TempDir::new().unwrap();
1287 let original = Config {
1288 adr_dir: PathBuf::from("doc/adr"),
1289 mode: ConfigMode::NextGen,
1290 no_edit: true,
1291 ..Default::default()
1292 };
1293 original.save(temp.path()).unwrap();
1294 let loaded = Config::load(temp.path()).unwrap();
1295 assert!(loaded.no_edit);
1296 }
1297
1298 #[test]
1301 fn test_load_empty_adr_dir_in_toml() {
1302 let temp = TempDir::new().unwrap();
1303 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
1304
1305 let result = Config::load(temp.path());
1306 assert!(
1307 result.is_err(),
1308 "Empty adr_dir in TOML should produce an error"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_load_whitespace_only_adr_dir_file() {
1314 let temp = TempDir::new().unwrap();
1315 std::fs::write(temp.path().join(".adr-dir"), " \n ").unwrap();
1316
1317 let result = Config::load(temp.path());
1318 assert!(
1319 result.is_err(),
1320 "Whitespace-only .adr-dir should produce an error"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_invalid_format_string_accepted_in_toml() {
1326 let temp = TempDir::new().unwrap();
1330 std::fs::write(
1331 temp.path().join("adrs.toml"),
1332 r#"
1333adr_dir = "doc/adr"
1334
1335[templates]
1336format = "nonexistent"
1337"#,
1338 )
1339 .unwrap();
1340
1341 let config = Config::load(temp.path()).unwrap();
1342 assert_eq!(config.templates.format, Some("nonexistent".to_string()));
1343 }
1344
1345 #[test]
1346 fn test_invalid_variant_string_accepted_in_toml() {
1347 let temp = TempDir::new().unwrap();
1348 std::fs::write(
1349 temp.path().join("adrs.toml"),
1350 r#"
1351adr_dir = "doc/adr"
1352
1353[templates]
1354variant = "bogus"
1355"#,
1356 )
1357 .unwrap();
1358
1359 let config = Config::load(temp.path()).unwrap();
1360 assert_eq!(config.templates.variant, Some("bogus".to_string()));
1361 }
1362
1363 #[test]
1364 fn test_invalid_mode_string_rejected() {
1365 let temp = TempDir::new().unwrap();
1366 std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
1367
1368 let result = Config::load(temp.path());
1369 assert!(
1370 result.is_err(),
1371 "Invalid mode should produce a TOML parse error"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_unknown_toml_fields_accepted() {
1377 let temp = TempDir::new().unwrap();
1381 std::fs::write(
1382 temp.path().join("adrs.toml"),
1383 r#"
1384adr_dir = "doc/adr"
1385unknown_field = "hello"
1386
1387[templates]
1388also_unknown = true
1389"#,
1390 )
1391 .unwrap();
1392
1393 let config = Config::load(temp.path()).unwrap();
1394 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1395 }
1396
1397 #[test]
1400 fn test_deserialize_config_reports_unknown_top_level_key() {
1401 let (config, unknown_keys) = deserialize_config(
1402 r#"
1403adr_dir = "doc/adr"
1404made_up_key = "hello"
1405"#,
1406 )
1407 .unwrap();
1408
1409 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1410 assert_eq!(unknown_keys, vec!["made_up_key".to_string()]);
1411 }
1412
1413 #[test]
1414 fn test_deserialize_config_reports_nested_doctor_key_as_dotted_path() {
1415 let (_config, unknown_keys) = deserialize_config(
1417 r#"
1418adr_dir = "doc/adr"
1419
1420[doctor]
1421path = "docs/decisions/0025-example.md"
1422ignore = ["ADR014"]
1423"#,
1424 )
1425 .unwrap();
1426
1427 assert_eq!(unknown_keys, vec!["doctor.path".to_string()]);
1428 }
1429
1430 #[test]
1431 fn test_deserialize_config_reports_typo_in_warnings_as_errors() {
1432 let (config, unknown_keys) = deserialize_config(
1433 r#"
1434adr_dir = "doc/adr"
1435
1436[doctor]
1437warnings_as_erors = true
1438"#,
1439 )
1440 .unwrap();
1441
1442 assert_eq!(unknown_keys, vec!["doctor.warnings_as_erors".to_string()]);
1444 assert!(!config.doctor.warnings_as_errors);
1447 }
1448
1449 #[test]
1450 fn test_deserialize_config_reports_nothing_for_valid_config() {
1451 let (config, unknown_keys) = deserialize_config(
1452 r#"
1453adr_dir = "doc/adr"
1454mode = "ng"
1455default_status = "accepted"
1456no_edit = true
1457
1458[templates]
1459format = "madr"
1460variant = "minimal"
1461custom = "templates/adr.md"
1462
1463[generate]
1464toc_prefix = "./"
1465
1466[export]
1467base_url = "https://example.com/adr"
1468
1469[doctor]
1470ignore = ["ADR011"]
1471warnings_as_errors = true
1472"#,
1473 )
1474 .unwrap();
1475
1476 assert!(
1477 unknown_keys.is_empty(),
1478 "expected no unknown keys, got {unknown_keys:?}"
1479 );
1480 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1481 }
1482
1483 #[test]
1484 fn test_deserialize_config_reports_unknown_key_in_templates() {
1485 let (_config, unknown_keys) = deserialize_config(
1486 r#"
1487adr_dir = "doc/adr"
1488
1489[templates]
1490format = "madr"
1491bogus = "value"
1492"#,
1493 )
1494 .unwrap();
1495
1496 assert_eq!(unknown_keys, vec!["templates.bogus".to_string()]);
1497 }
1498
1499 #[test]
1500 fn test_deserialize_config_reports_unknown_key_in_generate() {
1501 let (_config, unknown_keys) = deserialize_config(
1502 r#"
1503adr_dir = "doc/adr"
1504
1505[generate]
1506toc_prefix = "./"
1507bogus = "value"
1508"#,
1509 )
1510 .unwrap();
1511
1512 assert_eq!(unknown_keys, vec!["generate.bogus".to_string()]);
1513 }
1514
1515 #[test]
1516 fn test_deserialize_config_reports_unknown_key_in_export() {
1517 let (_config, unknown_keys) = deserialize_config(
1518 r#"
1519adr_dir = "doc/adr"
1520
1521[export]
1522base_url = "https://example.com/adr"
1523bogus = "value"
1524"#,
1525 )
1526 .unwrap();
1527
1528 assert_eq!(unknown_keys, vec!["export.bogus".to_string()]);
1529 }
1530
1531 #[test]
1532 fn test_deserialize_config_malformed_toml_still_errors() {
1533 let result = deserialize_config("this is not valid toml {{{");
1534 assert!(result.is_err(), "malformed TOML should still error");
1535 assert!(
1536 matches!(result, Err(Error::Toml(_))),
1537 "malformed TOML should still produce Error::Toml, unchanged from toml::from_str"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_deserialize_config_written_by_adrs_config_reports_no_unknown_keys() {
1543 let temp = TempDir::new().unwrap();
1547 let original = Config {
1548 adr_dir: PathBuf::from("docs/decisions"),
1549 mode: ConfigMode::NextGen,
1550 default_status: Some("accepted".to_string()),
1551 no_edit: true,
1552 templates: TemplateConfig {
1553 format: Some("madr".to_string()),
1554 variant: Some("minimal".to_string()),
1555 custom: Some(PathBuf::from("templates/adr.md")),
1556 },
1557 generate: GenerateConfig {
1558 toc_prefix: Some("./".to_string()),
1559 },
1560 export: ExportConfig {
1561 base_url: Some("https://example.com/adr".to_string()),
1562 },
1563 doctor: DoctorConfig {
1564 ignore: vec!["ADR011".to_string()],
1565 warnings_as_errors: true,
1566 ignore_path: Vec::new(),
1567 },
1568 };
1569 original.save(temp.path()).unwrap();
1570
1571 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1572 let (_config, unknown_keys) = deserialize_config(&content).unwrap();
1573
1574 assert!(
1575 unknown_keys.is_empty(),
1576 "a config written by Config::save should report no unknown keys, got {unknown_keys:?}"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_custom_template_path_in_config() {
1582 let temp = TempDir::new().unwrap();
1583 std::fs::write(
1584 temp.path().join("adrs.toml"),
1585 r#"
1586adr_dir = "doc/adr"
1587mode = "ng"
1588
1589[templates]
1590custom = "templates/my-adr.md"
1591"#,
1592 )
1593 .unwrap();
1594
1595 let config = Config::load(temp.path()).unwrap();
1596 assert_eq!(
1597 config.templates.custom,
1598 Some(PathBuf::from("templates/my-adr.md"))
1599 );
1600 }
1601
1602 #[test]
1605 fn test_save_and_load_roundtrip_nextgen_with_templates() {
1606 let temp = TempDir::new().unwrap();
1607 let original = Config {
1608 adr_dir: PathBuf::from("docs/decisions"),
1609 mode: ConfigMode::NextGen,
1610 default_status: None,
1611 no_edit: false,
1612 templates: TemplateConfig {
1613 format: Some("madr".to_string()),
1614 variant: Some("minimal".to_string()),
1615 custom: Some(PathBuf::from("templates/custom.md")),
1616 },
1617 generate: GenerateConfig::default(),
1618 export: ExportConfig::default(),
1619 doctor: DoctorConfig::default(),
1620 };
1621
1622 original.save(temp.path()).unwrap();
1623 let loaded = Config::load(temp.path()).unwrap();
1624
1625 assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1626 assert_eq!(loaded.mode, ConfigMode::NextGen);
1627 assert_eq!(loaded.templates.format, Some("madr".to_string()));
1628 assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1629 assert_eq!(
1630 loaded.templates.custom,
1631 Some(PathBuf::from("templates/custom.md"))
1632 );
1633 }
1634
1635 #[test]
1636 fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1637 let temp = TempDir::new().unwrap();
1639 let original = Config {
1640 mode: ConfigMode::NextGen,
1641 ..Default::default()
1642 };
1643
1644 original.save(temp.path()).unwrap();
1645
1646 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1648 assert!(content.contains(r#"mode = "ng""#));
1649
1650 let loaded = Config::load(temp.path()).unwrap();
1652 assert_eq!(loaded.mode, ConfigMode::NextGen);
1653 }
1654
1655 #[test]
1658 fn test_config_merge_variant_field() {
1659 let mut base = Config::default();
1660 let other = Config {
1661 templates: TemplateConfig {
1662 format: None,
1663 variant: Some("minimal".to_string()),
1664 custom: None,
1665 },
1666 ..Default::default()
1667 };
1668
1669 base.merge(&other);
1670 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1671 }
1672
1673 #[test]
1674 fn test_config_merge_custom_field() {
1675 let mut base = Config::default();
1676 let other = Config {
1677 templates: TemplateConfig {
1678 format: None,
1679 variant: None,
1680 custom: Some(PathBuf::from("my-template.md")),
1681 },
1682 ..Default::default()
1683 };
1684
1685 base.merge(&other);
1686 assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1687 }
1688
1689 #[test]
1690 fn test_config_merge_does_not_overwrite_with_none() {
1691 let mut base = Config {
1692 templates: TemplateConfig {
1693 format: Some("madr".to_string()),
1694 variant: Some("minimal".to_string()),
1695 custom: Some(PathBuf::from("template.md")),
1696 },
1697 ..Default::default()
1698 };
1699 let other = Config::default(); base.merge(&other);
1702
1703 assert_eq!(base.templates.format, Some("madr".to_string()));
1705 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1706 assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1707 }
1708
1709 #[test]
1712 fn test_generate_config_default() {
1713 let config = GenerateConfig::default();
1714 assert!(config.toc_prefix.is_none());
1715 }
1716
1717 #[test]
1718 fn test_generate_toc_prefix_from_toml() {
1719 let temp = TempDir::new().unwrap();
1720 std::fs::write(
1721 temp.path().join("adrs.toml"),
1722 r#"
1723adr_dir = "doc/adr"
1724
1725[generate]
1726toc_prefix = "./"
1727"#,
1728 )
1729 .unwrap();
1730
1731 let config = Config::load(temp.path()).unwrap();
1732 assert_eq!(config.generate.toc_prefix, Some("./".to_string()));
1733 }
1734
1735 #[test]
1736 fn test_generate_toc_prefix_absent_defaults_none() {
1737 let temp = TempDir::new().unwrap();
1738 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1739
1740 let config = Config::load(temp.path()).unwrap();
1741 assert!(config.generate.toc_prefix.is_none());
1742 }
1743
1744 #[test]
1745 fn test_config_merge_generate_toc_prefix() {
1746 let mut base = Config::default();
1747 let other = Config {
1748 generate: GenerateConfig {
1749 toc_prefix: Some("./".to_string()),
1750 },
1751 ..Default::default()
1752 };
1753
1754 base.merge(&other);
1755 assert_eq!(base.generate.toc_prefix, Some("./".to_string()));
1756 }
1757
1758 #[test]
1759 fn test_config_merge_generate_toc_prefix_none_does_not_overwrite() {
1760 let mut base = Config {
1761 generate: GenerateConfig {
1762 toc_prefix: Some("docs/".to_string()),
1763 },
1764 ..Default::default()
1765 };
1766 let other = Config::default(); base.merge(&other);
1769 assert_eq!(
1770 base.generate.toc_prefix,
1771 Some("docs/".to_string()),
1772 "merge with toc_prefix=None should not overwrite existing value"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_generate_toc_prefix_save_load_roundtrip() {
1778 let temp = TempDir::new().unwrap();
1779 let original = Config {
1780 mode: ConfigMode::NextGen,
1781 generate: GenerateConfig {
1782 toc_prefix: Some("wiki/adr/".to_string()),
1783 },
1784 ..Default::default()
1785 };
1786
1787 original.save(temp.path()).unwrap();
1788 let loaded = Config::load(temp.path()).unwrap();
1789
1790 assert_eq!(
1791 loaded.generate.toc_prefix,
1792 Some("wiki/adr/".to_string()),
1793 "toc_prefix should survive a save/load round-trip"
1794 );
1795 }
1796
1797 #[test]
1800 fn test_export_config_default() {
1801 let config = ExportConfig::default();
1802 assert!(config.base_url.is_none());
1803 }
1804
1805 #[test]
1806 fn test_export_base_url_from_toml() {
1807 let temp = TempDir::new().unwrap();
1808 std::fs::write(
1809 temp.path().join("adrs.toml"),
1810 r#"
1811adr_dir = "doc/adr"
1812
1813[export]
1814base_url = "https://github.com/org/repo/blob/main/doc/adr"
1815"#,
1816 )
1817 .unwrap();
1818
1819 let config = Config::load(temp.path()).unwrap();
1820 assert_eq!(
1821 config.export.base_url,
1822 Some("https://github.com/org/repo/blob/main/doc/adr".to_string())
1823 );
1824 }
1825
1826 #[test]
1827 fn test_export_base_url_absent_defaults_none() {
1828 let temp = TempDir::new().unwrap();
1829 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1830
1831 let config = Config::load(temp.path()).unwrap();
1832 assert!(config.export.base_url.is_none());
1833 }
1834
1835 #[test]
1836 fn test_config_merge_export_base_url() {
1837 let mut base = Config::default();
1838 let other = Config {
1839 export: ExportConfig {
1840 base_url: Some("https://example.com/adr".to_string()),
1841 },
1842 ..Default::default()
1843 };
1844
1845 base.merge(&other);
1846 assert_eq!(
1847 base.export.base_url,
1848 Some("https://example.com/adr".to_string())
1849 );
1850 }
1851
1852 #[test]
1853 fn test_config_merge_export_base_url_none_does_not_overwrite() {
1854 let mut base = Config {
1855 export: ExportConfig {
1856 base_url: Some("https://existing.example.com/adr".to_string()),
1857 },
1858 ..Default::default()
1859 };
1860 let other = Config::default(); base.merge(&other);
1863 assert_eq!(
1864 base.export.base_url,
1865 Some("https://existing.example.com/adr".to_string()),
1866 "merge with export.base_url=None should not overwrite existing value"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_export_base_url_save_load_roundtrip() {
1872 let temp = TempDir::new().unwrap();
1873 let original = Config {
1874 mode: ConfigMode::NextGen,
1875 export: ExportConfig {
1876 base_url: Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1877 },
1878 ..Default::default()
1879 };
1880
1881 original.save(temp.path()).unwrap();
1882 let loaded = Config::load(temp.path()).unwrap();
1883
1884 assert_eq!(
1885 loaded.export.base_url,
1886 Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1887 "export.base_url should survive a save/load round-trip"
1888 );
1889 }
1890
1891 #[test]
1894 fn test_doctor_config_default() {
1895 let config = DoctorConfig::default();
1896 assert!(config.ignore.is_empty());
1897 assert!(!config.warnings_as_errors);
1898 }
1899
1900 #[test]
1901 fn test_doctor_ignore_from_toml() {
1902 let temp = TempDir::new().unwrap();
1903 std::fs::write(
1904 temp.path().join("adrs.toml"),
1905 r#"
1906adr_dir = "doc/adr"
1907
1908[doctor]
1909ignore = ["ADR011", "MD013"]
1910"#,
1911 )
1912 .unwrap();
1913
1914 let config = Config::load(temp.path()).unwrap();
1915 assert_eq!(
1916 config.doctor.ignore,
1917 vec!["ADR011".to_string(), "MD013".to_string()]
1918 );
1919 }
1920
1921 #[test]
1922 fn test_doctor_warnings_as_errors_from_toml() {
1923 let temp = TempDir::new().unwrap();
1924 std::fs::write(
1925 temp.path().join("adrs.toml"),
1926 r#"
1927adr_dir = "doc/adr"
1928
1929[doctor]
1930warnings_as_errors = true
1931"#,
1932 )
1933 .unwrap();
1934
1935 let config = Config::load(temp.path()).unwrap();
1936 assert!(config.doctor.warnings_as_errors);
1937 }
1938
1939 #[test]
1940 fn test_doctor_config_absent_defaults() {
1941 let temp = TempDir::new().unwrap();
1942 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1943
1944 let config = Config::load(temp.path()).unwrap();
1945 assert!(config.doctor.ignore.is_empty());
1946 assert!(!config.doctor.warnings_as_errors);
1947 }
1948
1949 #[test]
1950 fn test_config_merge_doctor_ignore() {
1951 let mut base = Config::default();
1952 let other = Config {
1953 doctor: DoctorConfig {
1954 ignore: vec!["ADR011".to_string()],
1955 warnings_as_errors: false,
1956 ..Default::default()
1957 },
1958 ..Default::default()
1959 };
1960
1961 base.merge(&other);
1962 assert_eq!(base.doctor.ignore, vec!["ADR011".to_string()]);
1963
1964 let mut base = Config {
1966 doctor: DoctorConfig {
1967 ignore: vec!["ADR011".to_string()],
1968 warnings_as_errors: false,
1969 ..Default::default()
1970 },
1971 ..Default::default()
1972 };
1973 let other = Config::default(); base.merge(&other);
1976 assert_eq!(
1977 base.doctor.ignore,
1978 vec!["ADR011".to_string()],
1979 "merge with empty doctor.ignore should not overwrite existing value"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_config_merge_doctor_warnings_as_errors() {
1985 let mut base = Config {
1987 doctor: DoctorConfig {
1988 ignore: vec![],
1989 warnings_as_errors: true,
1990 ..Default::default()
1991 },
1992 ..Default::default()
1993 };
1994 let other = Config::default(); base.merge(&other);
1996 assert!(
1997 base.doctor.warnings_as_errors,
1998 "merge with warnings_as_errors=false should not overwrite true"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_doctor_config_save_load_roundtrip() {
2004 let temp = TempDir::new().unwrap();
2005 let original = Config {
2006 mode: ConfigMode::NextGen,
2007 doctor: DoctorConfig {
2008 ignore: vec!["ADR011".to_string()],
2009 warnings_as_errors: true,
2010 ..Default::default()
2011 },
2012 ..Default::default()
2013 };
2014
2015 original.save(temp.path()).unwrap();
2016 let loaded = Config::load(temp.path()).unwrap();
2017
2018 assert_eq!(loaded.doctor.ignore, vec!["ADR011".to_string()]);
2019 assert!(loaded.doctor.warnings_as_errors);
2020 }
2021
2022 #[test]
2025 fn test_doctor_ignore_path_default_empty() {
2026 let config = DoctorConfig::default();
2027 assert!(config.ignore_path.is_empty());
2028 }
2029
2030 #[test]
2031 fn test_doctor_ignore_path_from_toml() {
2032 let temp = TempDir::new().unwrap();
2033 std::fs::write(
2034 temp.path().join("adrs.toml"),
2035 r#"
2036adr_dir = "doc/adr"
2037
2038[doctor]
2039ignore = ["ADR011"]
2040
2041[[doctor.ignore_path]]
2042glob = "doc/adr/0025-*.md"
2043rules = ["ADR014"]
2044
2045[[doctor.ignore_path]]
2046glob = "doc/adr/legacy/**"
2047rules = ["ADR001", "adr-required-sections"]
2048"#,
2049 )
2050 .unwrap();
2051
2052 let config = Config::load(temp.path()).unwrap();
2053 assert_eq!(config.doctor.ignore, vec!["ADR011".to_string()]);
2054 assert_eq!(config.doctor.ignore_path.len(), 2);
2055 assert_eq!(config.doctor.ignore_path[0].glob, "doc/adr/0025-*.md");
2056 assert_eq!(
2057 config.doctor.ignore_path[0].rules,
2058 vec!["ADR014".to_string()]
2059 );
2060 assert_eq!(config.doctor.ignore_path[1].glob, "doc/adr/legacy/**");
2061 assert_eq!(
2062 config.doctor.ignore_path[1].rules,
2063 vec!["ADR001".to_string(), "adr-required-sections".to_string()]
2064 );
2065 }
2066
2067 #[test]
2068 fn test_doctor_ignore_path_absent_defaults_empty() {
2069 let temp = TempDir::new().unwrap();
2070 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
2071
2072 let config = Config::load(temp.path()).unwrap();
2073 assert!(config.doctor.ignore_path.is_empty());
2074 }
2075
2076 #[test]
2077 fn test_doctor_ignore_path_save_load_roundtrip() {
2078 let temp = TempDir::new().unwrap();
2079 let original = Config {
2080 mode: ConfigMode::NextGen,
2081 doctor: DoctorConfig {
2082 ignore: vec!["ADR011".to_string()],
2083 warnings_as_errors: true,
2084 ignore_path: vec![DoctorIgnorePath {
2085 glob: "doc/adr/0025-*.md".to_string(),
2086 rules: vec!["ADR014".to_string()],
2087 }],
2088 },
2089 ..Default::default()
2090 };
2091
2092 original.save(temp.path()).unwrap();
2093 let loaded = Config::load(temp.path()).unwrap();
2094
2095 assert_eq!(loaded.doctor.ignore_path.len(), 1);
2096 assert_eq!(loaded.doctor.ignore_path[0].glob, "doc/adr/0025-*.md");
2097 assert_eq!(
2098 loaded.doctor.ignore_path[0].rules,
2099 vec!["ADR014".to_string()]
2100 );
2101 }
2102
2103 #[test]
2104 fn test_config_merge_doctor_ignore_path() {
2105 let mut base = Config::default();
2106 let other = Config {
2107 doctor: DoctorConfig {
2108 ignore_path: vec![DoctorIgnorePath {
2109 glob: "doc/adr/0025-*.md".to_string(),
2110 rules: vec!["ADR014".to_string()],
2111 }],
2112 ..Default::default()
2113 },
2114 ..Default::default()
2115 };
2116
2117 base.merge(&other);
2118 assert_eq!(base.doctor.ignore_path.len(), 1);
2119 assert_eq!(base.doctor.ignore_path[0].glob, "doc/adr/0025-*.md");
2120
2121 let mut base = Config {
2123 doctor: DoctorConfig {
2124 ignore_path: vec![DoctorIgnorePath {
2125 glob: "doc/adr/0025-*.md".to_string(),
2126 rules: vec!["ADR014".to_string()],
2127 }],
2128 ..Default::default()
2129 },
2130 ..Default::default()
2131 };
2132 let other = Config::default(); base.merge(&other);
2135 assert_eq!(
2136 base.doctor.ignore_path.len(),
2137 1,
2138 "merge with empty doctor.ignore_path should not overwrite existing value"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_deserialize_config_reports_unknown_key_in_doctor_ignore_path() {
2144 let (_config, unknown_keys) = deserialize_config(
2148 r#"
2149adr_dir = "doc/adr"
2150
2151[[doctor.ignore_path]]
2152glob = "doc/adr/0025-*.md"
2153rules = ["ADR014"]
2154reason = "false positive on this record"
2155"#,
2156 )
2157 .unwrap();
2158
2159 assert_eq!(
2160 unknown_keys,
2161 vec!["doctor.ignore_path.0.reason".to_string()]
2162 );
2163 }
2164}