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
73impl Config {
74 pub fn load(root: &Path) -> Result<Self> {
81 let config_path = root.join(CONFIG_FILE);
83 if config_path.exists() {
84 let content = std::fs::read_to_string(&config_path)?;
85 let config: Config = toml::from_str(&content)?;
86 if config.adr_dir.as_os_str().is_empty() {
87 return Err(Error::ConfigError(
88 "adr_dir cannot be empty in adrs.toml".into(),
89 ));
90 }
91 return Ok(config);
92 }
93
94 let legacy_path = root.join(LEGACY_CONFIG_FILE);
96 if legacy_path.exists() {
97 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
98 if adr_dir.is_empty() {
99 return Err(Error::ConfigError(
100 "ADR directory path is empty in .adr-dir file".into(),
101 ));
102 }
103 return Ok(Self {
104 adr_dir: PathBuf::from(adr_dir),
105 mode: ConfigMode::Compatible,
106 default_status: None,
107 no_edit: false,
108 templates: TemplateConfig::default(),
109 generate: GenerateConfig::default(),
110 export: ExportConfig::default(),
111 doctor: DoctorConfig::default(),
112 });
113 }
114
115 let default_dir = root.join(DEFAULT_ADR_DIR);
117 if default_dir.exists() {
118 return Ok(Self::default());
119 }
120
121 Err(Error::AdrDirNotFound)
122 }
123
124 pub fn load_or_default(root: &Path) -> Self {
126 Self::load(root).unwrap_or_default()
127 }
128
129 pub fn save(&self, root: &Path) -> Result<()> {
131 match self.mode {
132 ConfigMode::Compatible => {
133 let path = root.join(LEGACY_CONFIG_FILE);
135 std::fs::write(&path, self.adr_dir.display().to_string())?;
136 }
137 ConfigMode::NextGen => {
138 let path = root.join(CONFIG_FILE);
140 let content =
141 toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
142 std::fs::write(&path, content)?;
143 }
144 }
145 Ok(())
146 }
147
148 pub fn adr_path(&self, root: &Path) -> PathBuf {
150 root.join(&self.adr_dir)
151 }
152
153 pub fn is_next_gen(&self) -> bool {
155 matches!(self.mode, ConfigMode::NextGen)
156 }
157
158 pub fn merge(&mut self, other: &Config) {
160 if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
162 self.adr_dir = other.adr_dir.clone();
163 }
164 self.mode = other.mode;
166 if other.templates.format.is_some() {
168 self.templates.format = other.templates.format.clone();
169 }
170 if other.templates.variant.is_some() {
171 self.templates.variant = other.templates.variant.clone();
172 }
173 if other.templates.custom.is_some() {
174 self.templates.custom = other.templates.custom.clone();
175 }
176 if other.default_status.is_some() {
177 self.default_status = other.default_status.clone();
178 }
179 if other.no_edit {
180 self.no_edit = other.no_edit;
181 }
182 if other.generate.toc_prefix.is_some() {
183 self.generate.toc_prefix = other.generate.toc_prefix.clone();
184 }
185 if other.export.base_url.is_some() {
186 self.export.base_url = other.export.base_url.clone();
187 }
188 if !other.doctor.ignore.is_empty() {
189 self.doctor.ignore = other.doctor.ignore.clone();
190 }
191 if other.doctor.warnings_as_errors {
192 self.doctor.warnings_as_errors = other.doctor.warnings_as_errors;
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
199pub struct DiscoveredConfig {
200 pub config: Config,
202 pub root: PathBuf,
204 pub source: ConfigSource,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ConfigSource {
211 Project(PathBuf),
213 Global(PathBuf),
215 Environment,
217 Default,
219}
220
221pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
231 if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
233 let path = PathBuf::from(&config_path);
234 if path.exists() {
235 let content = std::fs::read_to_string(&path)?;
236 let mut config: Config = toml::from_str(&content)?;
237 apply_env_overrides(&mut config);
238 return Ok(DiscoveredConfig {
239 config,
240 root: path
241 .parent()
242 .map(|p| p.to_path_buf())
243 .unwrap_or_else(|| start_dir.to_path_buf()),
244 source: ConfigSource::Environment,
245 });
246 }
247 }
248
249 if let Some((root, config, source)) = search_upward(start_dir)? {
251 let mut config = config;
252 apply_env_overrides(&mut config);
253 return Ok(DiscoveredConfig {
254 config,
255 root,
256 source,
257 });
258 }
259
260 if let Some((config, path)) = load_global_config()? {
262 let mut config = config;
263 apply_env_overrides(&mut config);
264 return Ok(DiscoveredConfig {
265 config,
266 root: start_dir.to_path_buf(),
267 source: ConfigSource::Global(path),
268 });
269 }
270
271 let mut config = Config::default();
273 apply_env_overrides(&mut config);
274 Ok(DiscoveredConfig {
275 config,
276 root: start_dir.to_path_buf(),
277 source: ConfigSource::Default,
278 })
279}
280
281fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
283 let mut current = start_dir.to_path_buf();
284
285 loop {
286 let config_path = current.join(CONFIG_FILE);
288 if config_path.exists() {
289 let content = std::fs::read_to_string(&config_path)?;
290 let config: Config = toml::from_str(&content)?;
291 return Ok(Some((current, config, ConfigSource::Project(config_path))));
292 }
293
294 let legacy_path = current.join(LEGACY_CONFIG_FILE);
296 if legacy_path.exists() {
297 let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
298 let config = Config {
299 adr_dir: PathBuf::from(adr_dir),
300 mode: ConfigMode::Compatible,
301 default_status: None,
302 no_edit: false,
303 templates: TemplateConfig::default(),
304 generate: GenerateConfig::default(),
305 export: ExportConfig::default(),
306 doctor: DoctorConfig::default(),
307 };
308 return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
309 }
310
311 let default_dir = current.join(DEFAULT_ADR_DIR);
313 if default_dir.exists() {
314 return Ok(Some((current, Config::default(), ConfigSource::Default)));
315 }
316
317 if current.join(".git").exists() {
319 break;
320 }
321
322 match current.parent() {
324 Some(parent) => current = parent.to_path_buf(),
325 None => break,
326 }
327 }
328
329 Ok(None)
330}
331
332fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
334 let config_dir = dirs_config_dir()?;
335 let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
336
337 if global_path.exists() {
338 let content = std::fs::read_to_string(&global_path)?;
339 let config: Config = toml::from_str(&content)?;
340 return Ok(Some((config, global_path)));
341 }
342
343 Ok(None)
344}
345
346fn dirs_config_dir() -> Result<PathBuf> {
348 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
350 return Ok(PathBuf::from(xdg));
351 }
352
353 if let Ok(home) = std::env::var("HOME") {
354 return Ok(PathBuf::from(home).join(".config"));
355 }
356
357 if let Ok(appdata) = std::env::var("APPDATA") {
359 return Ok(PathBuf::from(appdata));
360 }
361
362 Err(Error::ConfigError(
363 "Could not determine config directory".into(),
364 ))
365}
366
367fn apply_env_overrides(config: &mut Config) {
369 if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
370 config.adr_dir = PathBuf::from(adr_dir);
371 }
372}
373
374#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "lowercase")]
377pub enum ConfigMode {
378 #[default]
380 Compatible,
381
382 #[serde(rename = "ng", alias = "nextgen")]
384 NextGen,
385}
386
387#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389#[serde(default)]
390pub struct TemplateConfig {
391 pub format: Option<String>,
393
394 pub variant: Option<String>,
396
397 pub custom: Option<PathBuf>,
399}
400
401#[derive(Debug, Clone, Default, Serialize, Deserialize)]
403#[serde(default)]
404pub struct GenerateConfig {
405 pub toc_prefix: Option<String>,
407}
408
409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
411#[serde(default)]
412pub struct ExportConfig {
413 pub base_url: Option<String>,
415}
416
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
419#[serde(default)]
420pub struct DoctorConfig {
421 pub ignore: Vec<String>,
424
425 pub warnings_as_errors: bool,
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use tempfile::TempDir;
433 use test_case::test_case;
434
435 #[test]
438 fn test_default_config() {
439 let config = Config::default();
440 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
441 assert_eq!(config.mode, ConfigMode::Compatible);
442 assert!(config.default_status.is_none());
443 assert!(config.templates.format.is_none());
444 assert!(config.templates.custom.is_none());
445 }
446
447 #[test]
448 fn test_constants() {
449 assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
450 assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
451 assert_eq!(CONFIG_FILE, "adrs.toml");
452 }
453
454 #[test]
455 fn test_config_mode_default() {
456 assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
457 }
458
459 #[test]
462 fn test_load_legacy_config() {
463 let temp = TempDir::new().unwrap();
464 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
465
466 let config = Config::load(temp.path()).unwrap();
467 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
468 assert_eq!(config.mode, ConfigMode::Compatible);
469 }
470
471 #[test]
472 fn test_load_legacy_config_with_whitespace() {
473 let temp = TempDir::new().unwrap();
474 std::fs::write(temp.path().join(".adr-dir"), " decisions \n").unwrap();
475
476 let config = Config::load(temp.path()).unwrap();
477 assert_eq!(config.adr_dir, PathBuf::from("decisions"));
478 }
479
480 #[test]
481 fn test_load_legacy_config_nested_path() {
482 let temp = TempDir::new().unwrap();
483 std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
484
485 let config = Config::load(temp.path()).unwrap();
486 assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
487 }
488
489 #[test]
490 fn test_load_new_config() {
491 let temp = TempDir::new().unwrap();
492 std::fs::write(
493 temp.path().join("adrs.toml"),
494 r#"
495adr_dir = "docs/decisions"
496mode = "ng"
497"#,
498 )
499 .unwrap();
500
501 let config = Config::load(temp.path()).unwrap();
502 assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
503 assert_eq!(config.mode, ConfigMode::NextGen);
504 }
505
506 #[test]
507 fn test_load_new_config_compatible_mode() {
508 let temp = TempDir::new().unwrap();
509 std::fs::write(
510 temp.path().join("adrs.toml"),
511 r#"
512adr_dir = "doc/adr"
513mode = "compatible"
514"#,
515 )
516 .unwrap();
517
518 let config = Config::load(temp.path()).unwrap();
519 assert_eq!(config.mode, ConfigMode::Compatible);
520 }
521
522 #[test]
523 fn test_load_new_config_with_default_status() {
524 let temp = TempDir::new().unwrap();
525 std::fs::write(
526 temp.path().join("adrs.toml"),
527 r#"
528adr_dir = "doc/adr"
529default_status = "accepted"
530"#,
531 )
532 .unwrap();
533
534 let config = Config::load(temp.path()).unwrap();
535 assert_eq!(config.default_status, Some("accepted".to_string()));
536 }
537
538 #[test]
539 fn test_load_new_config_with_templates() {
540 let temp = TempDir::new().unwrap();
541 std::fs::write(
542 temp.path().join("adrs.toml"),
543 r#"
544adr_dir = "decisions"
545mode = "ng"
546
547[templates]
548format = "markdown"
549custom = "templates/adr.md"
550"#,
551 )
552 .unwrap();
553
554 let config = Config::load(temp.path()).unwrap();
555 assert_eq!(config.templates.format, Some("markdown".to_string()));
556 assert_eq!(
557 config.templates.custom,
558 Some(PathBuf::from("templates/adr.md"))
559 );
560 }
561
562 #[test]
563 fn test_load_new_config_with_template_variant() {
564 let temp = TempDir::new().unwrap();
565 std::fs::write(
566 temp.path().join("adrs.toml"),
567 r#"
568adr_dir = "decisions"
569mode = "ng"
570
571[templates]
572format = "madr"
573variant = "minimal"
574"#,
575 )
576 .unwrap();
577
578 let config = Config::load(temp.path()).unwrap();
579 assert_eq!(config.templates.format, Some("madr".to_string()));
580 assert_eq!(config.templates.variant, Some("minimal".to_string()));
581 }
582
583 #[test]
584 fn test_load_new_config_with_nextgen_alias() {
585 let temp = TempDir::new().unwrap();
586 std::fs::write(
587 temp.path().join("adrs.toml"),
588 r#"
589adr_dir = "decisions"
590mode = "nextgen"
591"#,
592 )
593 .unwrap();
594
595 let config = Config::load(temp.path()).unwrap();
596 assert_eq!(config.mode, ConfigMode::NextGen);
597 }
598
599 #[test]
600 fn test_load_new_config_minimal() {
601 let temp = TempDir::new().unwrap();
602 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
603
604 let config = Config::load(temp.path()).unwrap();
605 assert_eq!(config.adr_dir, PathBuf::from("adrs"));
606 assert_eq!(config.mode, ConfigMode::Compatible);
608 }
609
610 #[test]
611 fn test_load_prefers_new_config_over_legacy() {
612 let temp = TempDir::new().unwrap();
613 std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
615 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
616
617 let config = Config::load(temp.path()).unwrap();
618 assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
620 }
621
622 #[test]
623 fn test_load_default_dir_exists() {
624 let temp = TempDir::new().unwrap();
625 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
627
628 let config = Config::load(temp.path()).unwrap();
629 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
630 }
631
632 #[test]
633 fn test_load_no_config_no_default_dir() {
634 let temp = TempDir::new().unwrap();
635 let result = Config::load(temp.path());
638 assert!(result.is_err());
639 }
640
641 #[test]
642 fn test_load_or_default_returns_default_on_error() {
643 let temp = TempDir::new().unwrap();
644 let config = Config::load_or_default(temp.path());
647 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
648 assert_eq!(config.mode, ConfigMode::Compatible);
649 }
650
651 #[test]
652 fn test_load_or_default_returns_config_when_exists() {
653 let temp = TempDir::new().unwrap();
654 std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
655
656 let config = Config::load_or_default(temp.path());
657 assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
658 }
659
660 #[test]
663 fn test_save_legacy_config() {
664 let temp = TempDir::new().unwrap();
665 let config = Config {
666 adr_dir: PathBuf::from("my/adrs"),
667 mode: ConfigMode::Compatible,
668 default_status: None,
669 no_edit: false,
670 templates: TemplateConfig::default(),
671 generate: GenerateConfig::default(),
672 export: ExportConfig::default(),
673 doctor: DoctorConfig::default(),
674 };
675
676 config.save(temp.path()).unwrap();
677
678 let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
679 assert_eq!(content, "my/adrs");
680 assert!(!temp.path().join("adrs.toml").exists());
682 }
683
684 #[test]
685 fn test_save_new_config() {
686 let temp = TempDir::new().unwrap();
687 let config = Config {
688 adr_dir: PathBuf::from("docs/decisions"),
689 mode: ConfigMode::NextGen,
690 default_status: None,
691 no_edit: false,
692 templates: TemplateConfig::default(),
693 generate: GenerateConfig::default(),
694 export: ExportConfig::default(),
695 doctor: DoctorConfig::default(),
696 };
697
698 config.save(temp.path()).unwrap();
699
700 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
701 assert!(content.contains("docs/decisions"));
702 assert!(content.contains("ng"));
703 assert!(!temp.path().join(".adr-dir").exists());
705 }
706
707 #[test]
708 fn test_save_new_config_with_templates() {
709 let temp = TempDir::new().unwrap();
710 let config = Config {
711 adr_dir: PathBuf::from("decisions"),
712 mode: ConfigMode::NextGen,
713 default_status: None,
714 no_edit: false,
715 templates: TemplateConfig {
716 format: Some("custom".to_string()),
717 variant: None,
718 custom: Some(PathBuf::from("my-template.md")),
719 },
720 generate: GenerateConfig::default(),
721 export: ExportConfig::default(),
722 doctor: DoctorConfig::default(),
723 };
724
725 config.save(temp.path()).unwrap();
726
727 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
728 assert!(content.contains("custom"));
729 assert!(content.contains("my-template.md"));
730 }
731
732 #[test]
733 fn test_save_and_load_roundtrip_compatible() {
734 let temp = TempDir::new().unwrap();
735 let original = Config {
736 adr_dir: PathBuf::from("architecture/decisions"),
737 mode: ConfigMode::Compatible,
738 default_status: None,
739 no_edit: false,
740 templates: TemplateConfig::default(),
741 generate: GenerateConfig::default(),
742 export: ExportConfig::default(),
743 doctor: DoctorConfig::default(),
744 };
745
746 original.save(temp.path()).unwrap();
747 let loaded = Config::load(temp.path()).unwrap();
748
749 assert_eq!(loaded.adr_dir, original.adr_dir);
750 assert_eq!(loaded.mode, ConfigMode::Compatible);
751 }
752
753 #[test]
754 fn test_save_and_load_roundtrip_nextgen() {
755 let temp = TempDir::new().unwrap();
756 let original = Config {
757 adr_dir: PathBuf::from("docs/adr"),
758 mode: ConfigMode::NextGen,
759 default_status: None,
760 no_edit: false,
761 templates: TemplateConfig {
762 format: Some("markdown".to_string()),
763 variant: None,
764 custom: None,
765 },
766 generate: GenerateConfig::default(),
767 export: ExportConfig::default(),
768 doctor: DoctorConfig::default(),
769 };
770
771 original.save(temp.path()).unwrap();
772 let loaded = Config::load(temp.path()).unwrap();
773
774 assert_eq!(loaded.adr_dir, original.adr_dir);
775 assert_eq!(loaded.mode, ConfigMode::NextGen);
776 assert_eq!(loaded.templates.format, Some("markdown".to_string()));
777 }
778
779 #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
782 #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
783 #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
784 fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
785 let config = Config {
786 adr_dir: PathBuf::from(adr_dir),
787 ..Default::default()
788 };
789 config.adr_path(Path::new(root))
790 }
791
792 #[test]
793 fn test_is_next_gen() {
794 let compatible = Config {
795 mode: ConfigMode::Compatible,
796 ..Default::default()
797 };
798 assert!(!compatible.is_next_gen());
799
800 let nextgen = Config {
801 mode: ConfigMode::NextGen,
802 ..Default::default()
803 };
804 assert!(nextgen.is_next_gen());
805 }
806
807 #[test]
810 fn test_config_mode_equality() {
811 assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
812 assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
813 assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
814 }
815
816 #[test]
817 fn test_config_mode_serialization_in_config() {
818 let config = Config {
820 mode: ConfigMode::Compatible,
821 ..Default::default()
822 };
823 let toml = toml::to_string(&config).unwrap();
824 assert!(toml.contains("mode = \"compatible\""));
825
826 let config = Config {
827 mode: ConfigMode::NextGen,
828 ..Default::default()
829 };
830 let toml = toml::to_string(&config).unwrap();
831 assert!(toml.contains("mode = \"ng\""));
832 }
833
834 #[test]
835 fn test_config_mode_deserialization_in_config() {
836 let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
837 assert_eq!(config.mode, ConfigMode::Compatible);
838
839 let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
840 assert_eq!(config.mode, ConfigMode::NextGen);
841 }
842
843 #[test]
844 fn test_config_mode_deserialization_nextgen_alias() {
845 let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
846 assert_eq!(config.mode, ConfigMode::NextGen);
847 }
848
849 #[test]
852 fn test_template_config_default() {
853 let config = TemplateConfig::default();
854 assert!(config.format.is_none());
855 assert!(config.variant.is_none());
856 assert!(config.custom.is_none());
857 }
858
859 #[test]
860 fn test_template_config_serialization() {
861 let config = TemplateConfig {
862 format: Some("nygard".to_string()),
863 variant: None,
864 custom: Some(PathBuf::from("templates/custom.md")),
865 };
866
867 let toml = toml::to_string(&config).unwrap();
868 assert!(toml.contains("nygard"));
869 assert!(toml.contains("templates/custom.md"));
870 }
871
872 #[test]
875 fn test_load_invalid_toml() {
876 let temp = TempDir::new().unwrap();
877 std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
878
879 let result = Config::load(temp.path());
880 assert!(result.is_err());
881 }
882
883 #[test]
884 fn test_load_empty_toml() {
885 let temp = TempDir::new().unwrap();
886 std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
887
888 let config = Config::load(temp.path()).unwrap();
890 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
891 }
892
893 #[test]
894 fn test_load_empty_adr_dir_file() {
895 let temp = TempDir::new().unwrap();
896 std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
897
898 let result = Config::load(temp.path());
899 assert!(result.is_err(), "Empty .adr-dir should produce an error");
900 }
901
902 #[test]
905 fn test_discover_finds_config_in_current_dir() {
906 let temp = TempDir::new().unwrap();
907 std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
908
909 let discovered = discover(temp.path()).unwrap();
910 assert_eq!(discovered.root, temp.path());
911 assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
912 assert!(matches!(discovered.source, ConfigSource::Project(_)));
913 }
914
915 #[test]
916 fn test_discover_finds_config_in_parent_dir() {
917 let temp = TempDir::new().unwrap();
918 let subdir = temp.path().join("src").join("lib");
919 std::fs::create_dir_all(&subdir).unwrap();
920 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
921
922 let discovered = discover(&subdir).unwrap();
923 assert_eq!(discovered.root, temp.path());
924 assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
925 }
926
927 #[test]
928 fn test_discover_stops_at_git_root() {
929 let temp = TempDir::new().unwrap();
930
931 std::fs::create_dir(temp.path().join(".git")).unwrap();
933 let subdir = temp.path().join("src");
934 std::fs::create_dir(&subdir).unwrap();
935
936 let result = discover(&subdir);
940 assert!(result.is_ok());
942 let discovered = result.unwrap();
943 assert!(matches!(discovered.source, ConfigSource::Default));
944 }
945
946 #[test]
947 fn test_discover_prefers_adrs_toml_over_adr_dir() {
948 let temp = TempDir::new().unwrap();
949 std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
950 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
951
952 let discovered = discover(temp.path()).unwrap();
953 assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
954 }
955
956 #[test]
957 fn test_discover_finds_default_adr_dir() {
958 let temp = TempDir::new().unwrap();
959 std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
960
961 let discovered = discover(temp.path()).unwrap();
962 assert_eq!(discovered.root, temp.path());
963 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
964 }
965
966 #[test]
967 fn test_discover_returns_defaults_when_nothing_found() {
968 let temp = TempDir::new().unwrap();
969 std::fs::create_dir(temp.path().join(".git")).unwrap();
971
972 let discovered = discover(temp.path()).unwrap();
973 assert!(matches!(discovered.source, ConfigSource::Default));
974 assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
975 }
976
977 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
983
984 #[test]
985 fn test_apply_env_overrides() {
986 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
987 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
989 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
991
992 let mut config = Config::default();
993 apply_env_overrides(&mut config);
994
995 unsafe {
996 if let Some(v) = old {
997 std::env::set_var(ENV_ADR_DIRECTORY, v);
998 }
999 }
1000
1001 assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
1003 }
1004
1005 #[test]
1011 fn test_apply_env_overrides_sets_adr_dir() {
1012 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1013 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1015
1016 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
1018 let mut config = Config::default();
1019 apply_env_overrides(&mut config);
1020
1021 unsafe {
1023 match old {
1024 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1025 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1026 }
1027 }
1028
1029 assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
1030 }
1031
1032 #[test]
1033 fn test_apply_env_overrides_overrides_non_default_adr_dir() {
1034 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1035 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1037
1038 unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
1040 let mut config = Config {
1041 adr_dir: PathBuf::from("config_dir"),
1042 ..Default::default()
1043 };
1044 apply_env_overrides(&mut config);
1045
1046 unsafe {
1047 match old {
1048 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1049 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1050 }
1051 }
1052
1053 assert_eq!(config.adr_dir, PathBuf::from("env_override"));
1054 }
1055
1056 #[test]
1057 fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
1058 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1059 let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1061
1062 unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
1064
1065 let mut config = Config {
1066 adr_dir: PathBuf::from("original/path"),
1067 ..Default::default()
1068 };
1069 apply_env_overrides(&mut config);
1070
1071 unsafe {
1072 match old {
1073 Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1074 None => std::env::remove_var(ENV_ADR_DIRECTORY),
1075 }
1076 }
1077
1078 assert_eq!(config.adr_dir, PathBuf::from("original/path"));
1079 }
1080
1081 #[test]
1082 fn test_config_source_variants() {
1083 let project = ConfigSource::Project(PathBuf::from("test"));
1085 let global = ConfigSource::Global(PathBuf::from("test"));
1086 let env = ConfigSource::Environment;
1087 let default = ConfigSource::Default;
1088
1089 assert_ne!(project, global);
1090 assert_ne!(env, default);
1091 assert_eq!(default, ConfigSource::Default);
1092 }
1093
1094 #[test]
1095 fn test_config_merge() {
1096 let mut base = Config::default();
1097 let other = Config {
1098 adr_dir: PathBuf::from("custom"),
1099 mode: ConfigMode::NextGen,
1100 default_status: None,
1101 no_edit: false,
1102 templates: TemplateConfig {
1103 format: Some("madr".to_string()),
1104 variant: None,
1105 custom: None,
1106 },
1107 generate: GenerateConfig::default(),
1108 export: ExportConfig::default(),
1109 doctor: DoctorConfig::default(),
1110 };
1111
1112 base.merge(&other);
1113 assert_eq!(base.adr_dir, PathBuf::from("custom"));
1114 assert_eq!(base.mode, ConfigMode::NextGen);
1115 assert_eq!(base.templates.format, Some("madr".to_string()));
1116 }
1117
1118 #[test]
1119 fn test_config_merge_preserves_default_adr_dir() {
1120 let mut base = Config {
1121 adr_dir: PathBuf::from("original"),
1122 ..Default::default()
1123 };
1124 let other = Config::default(); base.merge(&other);
1127 assert_eq!(base.adr_dir, PathBuf::from("original"));
1129 }
1130
1131 #[test]
1132 fn test_config_merge_default_status() {
1133 let mut base = Config::default();
1134 let other = Config {
1135 default_status: Some("accepted".to_string()),
1136 ..Default::default()
1137 };
1138
1139 base.merge(&other);
1140 assert_eq!(base.default_status, Some("accepted".to_string()));
1141 }
1142
1143 #[test]
1146 fn test_no_edit_defaults_to_false() {
1147 let config = Config::default();
1148 assert!(!config.no_edit);
1149 }
1150
1151 #[test]
1152 fn test_no_edit_true_from_toml() {
1153 let temp = TempDir::new().unwrap();
1154 std::fs::write(
1155 temp.path().join("adrs.toml"),
1156 "adr_dir = \"doc/adr\"\nno_edit = true\n",
1157 )
1158 .unwrap();
1159 let config = Config::load(temp.path()).unwrap();
1160 assert!(config.no_edit);
1161 }
1162
1163 #[test]
1164 fn test_no_edit_false_from_toml() {
1165 let temp = TempDir::new().unwrap();
1166 std::fs::write(
1167 temp.path().join("adrs.toml"),
1168 "adr_dir = \"doc/adr\"\nno_edit = false\n",
1169 )
1170 .unwrap();
1171 let config = Config::load(temp.path()).unwrap();
1172 assert!(!config.no_edit);
1173 }
1174
1175 #[test]
1176 fn test_no_edit_absent_from_toml_defaults_false() {
1177 let temp = TempDir::new().unwrap();
1178 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1179 let config = Config::load(temp.path()).unwrap();
1180 assert!(!config.no_edit);
1181 }
1182
1183 #[test]
1184 fn test_config_merge_no_edit() {
1185 let mut base = Config::default();
1186 let other = Config {
1187 no_edit: true,
1188 ..Default::default()
1189 };
1190 base.merge(&other);
1191 assert!(base.no_edit);
1192 }
1193
1194 #[test]
1195 fn test_config_merge_no_edit_false_does_not_overwrite_true() {
1196 let mut base = Config {
1198 no_edit: true,
1199 ..Default::default()
1200 };
1201 let other = Config::default(); base.merge(&other);
1203 assert!(
1204 base.no_edit,
1205 "merge with no_edit=false should not overwrite true"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_no_edit_save_load_roundtrip() {
1211 let temp = TempDir::new().unwrap();
1212 let original = Config {
1213 adr_dir: PathBuf::from("doc/adr"),
1214 mode: ConfigMode::NextGen,
1215 no_edit: true,
1216 ..Default::default()
1217 };
1218 original.save(temp.path()).unwrap();
1219 let loaded = Config::load(temp.path()).unwrap();
1220 assert!(loaded.no_edit);
1221 }
1222
1223 #[test]
1226 fn test_load_empty_adr_dir_in_toml() {
1227 let temp = TempDir::new().unwrap();
1228 std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
1229
1230 let result = Config::load(temp.path());
1231 assert!(
1232 result.is_err(),
1233 "Empty adr_dir in TOML should produce an error"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_load_whitespace_only_adr_dir_file() {
1239 let temp = TempDir::new().unwrap();
1240 std::fs::write(temp.path().join(".adr-dir"), " \n ").unwrap();
1241
1242 let result = Config::load(temp.path());
1243 assert!(
1244 result.is_err(),
1245 "Whitespace-only .adr-dir should produce an error"
1246 );
1247 }
1248
1249 #[test]
1250 fn test_invalid_format_string_accepted_in_toml() {
1251 let temp = TempDir::new().unwrap();
1255 std::fs::write(
1256 temp.path().join("adrs.toml"),
1257 r#"
1258adr_dir = "doc/adr"
1259
1260[templates]
1261format = "nonexistent"
1262"#,
1263 )
1264 .unwrap();
1265
1266 let config = Config::load(temp.path()).unwrap();
1267 assert_eq!(config.templates.format, Some("nonexistent".to_string()));
1268 }
1269
1270 #[test]
1271 fn test_invalid_variant_string_accepted_in_toml() {
1272 let temp = TempDir::new().unwrap();
1273 std::fs::write(
1274 temp.path().join("adrs.toml"),
1275 r#"
1276adr_dir = "doc/adr"
1277
1278[templates]
1279variant = "bogus"
1280"#,
1281 )
1282 .unwrap();
1283
1284 let config = Config::load(temp.path()).unwrap();
1285 assert_eq!(config.templates.variant, Some("bogus".to_string()));
1286 }
1287
1288 #[test]
1289 fn test_invalid_mode_string_rejected() {
1290 let temp = TempDir::new().unwrap();
1291 std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
1292
1293 let result = Config::load(temp.path());
1294 assert!(
1295 result.is_err(),
1296 "Invalid mode should produce a TOML parse error"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_unknown_toml_fields_accepted() {
1302 let temp = TempDir::new().unwrap();
1304 std::fs::write(
1305 temp.path().join("adrs.toml"),
1306 r#"
1307adr_dir = "doc/adr"
1308unknown_field = "hello"
1309
1310[templates]
1311also_unknown = true
1312"#,
1313 )
1314 .unwrap();
1315
1316 let config = Config::load(temp.path()).unwrap();
1317 assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1318 }
1319
1320 #[test]
1321 fn test_custom_template_path_in_config() {
1322 let temp = TempDir::new().unwrap();
1323 std::fs::write(
1324 temp.path().join("adrs.toml"),
1325 r#"
1326adr_dir = "doc/adr"
1327mode = "ng"
1328
1329[templates]
1330custom = "templates/my-adr.md"
1331"#,
1332 )
1333 .unwrap();
1334
1335 let config = Config::load(temp.path()).unwrap();
1336 assert_eq!(
1337 config.templates.custom,
1338 Some(PathBuf::from("templates/my-adr.md"))
1339 );
1340 }
1341
1342 #[test]
1345 fn test_save_and_load_roundtrip_nextgen_with_templates() {
1346 let temp = TempDir::new().unwrap();
1347 let original = Config {
1348 adr_dir: PathBuf::from("docs/decisions"),
1349 mode: ConfigMode::NextGen,
1350 default_status: None,
1351 no_edit: false,
1352 templates: TemplateConfig {
1353 format: Some("madr".to_string()),
1354 variant: Some("minimal".to_string()),
1355 custom: Some(PathBuf::from("templates/custom.md")),
1356 },
1357 generate: GenerateConfig::default(),
1358 export: ExportConfig::default(),
1359 doctor: DoctorConfig::default(),
1360 };
1361
1362 original.save(temp.path()).unwrap();
1363 let loaded = Config::load(temp.path()).unwrap();
1364
1365 assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1366 assert_eq!(loaded.mode, ConfigMode::NextGen);
1367 assert_eq!(loaded.templates.format, Some("madr".to_string()));
1368 assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1369 assert_eq!(
1370 loaded.templates.custom,
1371 Some(PathBuf::from("templates/custom.md"))
1372 );
1373 }
1374
1375 #[test]
1376 fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1377 let temp = TempDir::new().unwrap();
1379 let original = Config {
1380 mode: ConfigMode::NextGen,
1381 ..Default::default()
1382 };
1383
1384 original.save(temp.path()).unwrap();
1385
1386 let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1388 assert!(content.contains(r#"mode = "ng""#));
1389
1390 let loaded = Config::load(temp.path()).unwrap();
1392 assert_eq!(loaded.mode, ConfigMode::NextGen);
1393 }
1394
1395 #[test]
1398 fn test_config_merge_variant_field() {
1399 let mut base = Config::default();
1400 let other = Config {
1401 templates: TemplateConfig {
1402 format: None,
1403 variant: Some("minimal".to_string()),
1404 custom: None,
1405 },
1406 ..Default::default()
1407 };
1408
1409 base.merge(&other);
1410 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1411 }
1412
1413 #[test]
1414 fn test_config_merge_custom_field() {
1415 let mut base = Config::default();
1416 let other = Config {
1417 templates: TemplateConfig {
1418 format: None,
1419 variant: None,
1420 custom: Some(PathBuf::from("my-template.md")),
1421 },
1422 ..Default::default()
1423 };
1424
1425 base.merge(&other);
1426 assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1427 }
1428
1429 #[test]
1430 fn test_config_merge_does_not_overwrite_with_none() {
1431 let mut base = Config {
1432 templates: TemplateConfig {
1433 format: Some("madr".to_string()),
1434 variant: Some("minimal".to_string()),
1435 custom: Some(PathBuf::from("template.md")),
1436 },
1437 ..Default::default()
1438 };
1439 let other = Config::default(); base.merge(&other);
1442
1443 assert_eq!(base.templates.format, Some("madr".to_string()));
1445 assert_eq!(base.templates.variant, Some("minimal".to_string()));
1446 assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1447 }
1448
1449 #[test]
1452 fn test_generate_config_default() {
1453 let config = GenerateConfig::default();
1454 assert!(config.toc_prefix.is_none());
1455 }
1456
1457 #[test]
1458 fn test_generate_toc_prefix_from_toml() {
1459 let temp = TempDir::new().unwrap();
1460 std::fs::write(
1461 temp.path().join("adrs.toml"),
1462 r#"
1463adr_dir = "doc/adr"
1464
1465[generate]
1466toc_prefix = "./"
1467"#,
1468 )
1469 .unwrap();
1470
1471 let config = Config::load(temp.path()).unwrap();
1472 assert_eq!(config.generate.toc_prefix, Some("./".to_string()));
1473 }
1474
1475 #[test]
1476 fn test_generate_toc_prefix_absent_defaults_none() {
1477 let temp = TempDir::new().unwrap();
1478 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1479
1480 let config = Config::load(temp.path()).unwrap();
1481 assert!(config.generate.toc_prefix.is_none());
1482 }
1483
1484 #[test]
1485 fn test_config_merge_generate_toc_prefix() {
1486 let mut base = Config::default();
1487 let other = Config {
1488 generate: GenerateConfig {
1489 toc_prefix: Some("./".to_string()),
1490 },
1491 ..Default::default()
1492 };
1493
1494 base.merge(&other);
1495 assert_eq!(base.generate.toc_prefix, Some("./".to_string()));
1496 }
1497
1498 #[test]
1499 fn test_config_merge_generate_toc_prefix_none_does_not_overwrite() {
1500 let mut base = Config {
1501 generate: GenerateConfig {
1502 toc_prefix: Some("docs/".to_string()),
1503 },
1504 ..Default::default()
1505 };
1506 let other = Config::default(); base.merge(&other);
1509 assert_eq!(
1510 base.generate.toc_prefix,
1511 Some("docs/".to_string()),
1512 "merge with toc_prefix=None should not overwrite existing value"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_generate_toc_prefix_save_load_roundtrip() {
1518 let temp = TempDir::new().unwrap();
1519 let original = Config {
1520 mode: ConfigMode::NextGen,
1521 generate: GenerateConfig {
1522 toc_prefix: Some("wiki/adr/".to_string()),
1523 },
1524 ..Default::default()
1525 };
1526
1527 original.save(temp.path()).unwrap();
1528 let loaded = Config::load(temp.path()).unwrap();
1529
1530 assert_eq!(
1531 loaded.generate.toc_prefix,
1532 Some("wiki/adr/".to_string()),
1533 "toc_prefix should survive a save/load round-trip"
1534 );
1535 }
1536
1537 #[test]
1540 fn test_export_config_default() {
1541 let config = ExportConfig::default();
1542 assert!(config.base_url.is_none());
1543 }
1544
1545 #[test]
1546 fn test_export_base_url_from_toml() {
1547 let temp = TempDir::new().unwrap();
1548 std::fs::write(
1549 temp.path().join("adrs.toml"),
1550 r#"
1551adr_dir = "doc/adr"
1552
1553[export]
1554base_url = "https://github.com/org/repo/blob/main/doc/adr"
1555"#,
1556 )
1557 .unwrap();
1558
1559 let config = Config::load(temp.path()).unwrap();
1560 assert_eq!(
1561 config.export.base_url,
1562 Some("https://github.com/org/repo/blob/main/doc/adr".to_string())
1563 );
1564 }
1565
1566 #[test]
1567 fn test_export_base_url_absent_defaults_none() {
1568 let temp = TempDir::new().unwrap();
1569 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1570
1571 let config = Config::load(temp.path()).unwrap();
1572 assert!(config.export.base_url.is_none());
1573 }
1574
1575 #[test]
1576 fn test_config_merge_export_base_url() {
1577 let mut base = Config::default();
1578 let other = Config {
1579 export: ExportConfig {
1580 base_url: Some("https://example.com/adr".to_string()),
1581 },
1582 ..Default::default()
1583 };
1584
1585 base.merge(&other);
1586 assert_eq!(
1587 base.export.base_url,
1588 Some("https://example.com/adr".to_string())
1589 );
1590 }
1591
1592 #[test]
1593 fn test_config_merge_export_base_url_none_does_not_overwrite() {
1594 let mut base = Config {
1595 export: ExportConfig {
1596 base_url: Some("https://existing.example.com/adr".to_string()),
1597 },
1598 ..Default::default()
1599 };
1600 let other = Config::default(); base.merge(&other);
1603 assert_eq!(
1604 base.export.base_url,
1605 Some("https://existing.example.com/adr".to_string()),
1606 "merge with export.base_url=None should not overwrite existing value"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_export_base_url_save_load_roundtrip() {
1612 let temp = TempDir::new().unwrap();
1613 let original = Config {
1614 mode: ConfigMode::NextGen,
1615 export: ExportConfig {
1616 base_url: Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1617 },
1618 ..Default::default()
1619 };
1620
1621 original.save(temp.path()).unwrap();
1622 let loaded = Config::load(temp.path()).unwrap();
1623
1624 assert_eq!(
1625 loaded.export.base_url,
1626 Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1627 "export.base_url should survive a save/load round-trip"
1628 );
1629 }
1630
1631 #[test]
1634 fn test_doctor_config_default() {
1635 let config = DoctorConfig::default();
1636 assert!(config.ignore.is_empty());
1637 assert!(!config.warnings_as_errors);
1638 }
1639
1640 #[test]
1641 fn test_doctor_ignore_from_toml() {
1642 let temp = TempDir::new().unwrap();
1643 std::fs::write(
1644 temp.path().join("adrs.toml"),
1645 r#"
1646adr_dir = "doc/adr"
1647
1648[doctor]
1649ignore = ["ADR011", "MD013"]
1650"#,
1651 )
1652 .unwrap();
1653
1654 let config = Config::load(temp.path()).unwrap();
1655 assert_eq!(
1656 config.doctor.ignore,
1657 vec!["ADR011".to_string(), "MD013".to_string()]
1658 );
1659 }
1660
1661 #[test]
1662 fn test_doctor_warnings_as_errors_from_toml() {
1663 let temp = TempDir::new().unwrap();
1664 std::fs::write(
1665 temp.path().join("adrs.toml"),
1666 r#"
1667adr_dir = "doc/adr"
1668
1669[doctor]
1670warnings_as_errors = true
1671"#,
1672 )
1673 .unwrap();
1674
1675 let config = Config::load(temp.path()).unwrap();
1676 assert!(config.doctor.warnings_as_errors);
1677 }
1678
1679 #[test]
1680 fn test_doctor_config_absent_defaults() {
1681 let temp = TempDir::new().unwrap();
1682 std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1683
1684 let config = Config::load(temp.path()).unwrap();
1685 assert!(config.doctor.ignore.is_empty());
1686 assert!(!config.doctor.warnings_as_errors);
1687 }
1688
1689 #[test]
1690 fn test_config_merge_doctor_ignore() {
1691 let mut base = Config::default();
1692 let other = Config {
1693 doctor: DoctorConfig {
1694 ignore: vec!["ADR011".to_string()],
1695 warnings_as_errors: false,
1696 },
1697 ..Default::default()
1698 };
1699
1700 base.merge(&other);
1701 assert_eq!(base.doctor.ignore, vec!["ADR011".to_string()]);
1702
1703 let mut base = Config {
1705 doctor: DoctorConfig {
1706 ignore: vec!["ADR011".to_string()],
1707 warnings_as_errors: false,
1708 },
1709 ..Default::default()
1710 };
1711 let other = Config::default(); base.merge(&other);
1714 assert_eq!(
1715 base.doctor.ignore,
1716 vec!["ADR011".to_string()],
1717 "merge with empty doctor.ignore should not overwrite existing value"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_config_merge_doctor_warnings_as_errors() {
1723 let mut base = Config {
1725 doctor: DoctorConfig {
1726 ignore: vec![],
1727 warnings_as_errors: true,
1728 },
1729 ..Default::default()
1730 };
1731 let other = Config::default(); base.merge(&other);
1733 assert!(
1734 base.doctor.warnings_as_errors,
1735 "merge with warnings_as_errors=false should not overwrite true"
1736 );
1737 }
1738
1739 #[test]
1740 fn test_doctor_config_save_load_roundtrip() {
1741 let temp = TempDir::new().unwrap();
1742 let original = Config {
1743 mode: ConfigMode::NextGen,
1744 doctor: DoctorConfig {
1745 ignore: vec!["ADR011".to_string()],
1746 warnings_as_errors: true,
1747 },
1748 ..Default::default()
1749 };
1750
1751 original.save(temp.path()).unwrap();
1752 let loaded = Config::load(temp.path()).unwrap();
1753
1754 assert_eq!(loaded.doctor.ignore, vec!["ADR011".to_string()]);
1755 assert!(loaded.doctor.warnings_as_errors);
1756 }
1757}