Skip to main content

adrs_core/
config.rs

1//! Configuration handling for ADR repositories.
2
3use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// Default ADR directory name.
8pub const DEFAULT_ADR_DIR: &str = "doc/adr";
9
10/// Legacy configuration file name (adr-tools compatible).
11pub const LEGACY_CONFIG_FILE: &str = ".adr-dir";
12
13/// New configuration file name.
14pub const CONFIG_FILE: &str = "adrs.toml";
15
16/// Global configuration file name.
17pub const GLOBAL_CONFIG_FILE: &str = "config.toml";
18
19/// Environment variable for ADR directory override.
20pub const ENV_ADR_DIRECTORY: &str = "ADR_DIRECTORY";
21
22/// Environment variable for config file path override.
23pub const ENV_ADRS_CONFIG: &str = "ADRS_CONFIG";
24
25/// Configuration for an ADR repository.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28pub struct Config {
29    /// The directory where ADRs are stored.
30    pub adr_dir: PathBuf,
31
32    /// The mode of operation.
33    pub mode: ConfigMode,
34
35    /// Default status for newly created ADRs.
36    pub default_status: Option<String>,
37
38    /// Skip opening the editor when creating a new ADR.
39    pub no_edit: bool,
40
41    /// Template configuration.
42    #[serde(default)]
43    pub templates: TemplateConfig,
44
45    /// Generate command configuration.
46    #[serde(default)]
47    pub generate: GenerateConfig,
48
49    /// Export command configuration.
50    #[serde(default)]
51    pub export: ExportConfig,
52
53    /// Doctor command configuration.
54    #[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    /// Load configuration from the given directory.
75    ///
76    /// Searches for configuration in the following order:
77    /// 1. `adrs.toml` (new format)
78    /// 2. `.adr-dir` (legacy adr-tools format)
79    /// 3. Default configuration
80    pub fn load(root: &Path) -> Result<Self> {
81        // Try new config first
82        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        // Try legacy .adr-dir file
95        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        // Check if default directory exists
116        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    /// Load configuration, or return default if not found.
125    pub fn load_or_default(root: &Path) -> Self {
126        Self::load(root).unwrap_or_default()
127    }
128
129    /// Save configuration to the given directory.
130    pub fn save(&self, root: &Path) -> Result<()> {
131        match self.mode {
132            ConfigMode::Compatible => {
133                // Write legacy .adr-dir file
134                let path = root.join(LEGACY_CONFIG_FILE);
135                std::fs::write(&path, self.adr_dir.display().to_string())?;
136            }
137            ConfigMode::NextGen => {
138                // Write adrs.toml
139                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    /// Returns the full path to the ADR directory.
149    pub fn adr_path(&self, root: &Path) -> PathBuf {
150        root.join(&self.adr_dir)
151    }
152
153    /// Returns true if running in next-gen mode.
154    pub fn is_next_gen(&self) -> bool {
155        matches!(self.mode, ConfigMode::NextGen)
156    }
157
158    /// Merge another config into this one (other takes precedence for set values).
159    pub fn merge(&mut self, other: &Config) {
160        // adr_dir: use other if it differs from default
161        if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
162            self.adr_dir = other.adr_dir.clone();
163        }
164        // mode: other takes precedence
165        self.mode = other.mode;
166        // templates: merge
167        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/// Result of discovering configuration.
198#[derive(Debug, Clone)]
199pub struct DiscoveredConfig {
200    /// The resolved configuration.
201    pub config: Config,
202    /// The project root directory (where config was found).
203    pub root: PathBuf,
204    /// Where the config was loaded from.
205    pub source: ConfigSource,
206}
207
208/// Where the configuration was loaded from.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ConfigSource {
211    /// Loaded from project config file.
212    Project(PathBuf),
213    /// Loaded from global config file.
214    Global(PathBuf),
215    /// Loaded from environment variable.
216    Environment,
217    /// Using defaults (no config found).
218    Default,
219}
220
221/// Discover configuration by searching up the directory tree.
222///
223/// Search order:
224/// 1. Environment variable `ADRS_CONFIG` (explicit config path)
225/// 2. Search upward from `start_dir` for `.adr-dir` or `adrs.toml`
226/// 3. Global config at `~/.config/adrs/config.toml`
227/// 4. Default configuration
228///
229/// Environment variable `ADR_DIRECTORY` overrides the ADR directory.
230pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
231    // Check for explicit config path from environment
232    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    // Search upward for project config
250    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    // Try global config
261    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    // Use defaults
272    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
281/// Search upward from the given directory for a config file.
282fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
283    let mut current = start_dir.to_path_buf();
284
285    loop {
286        // Check for adrs.toml first
287        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        // Check for .adr-dir
295        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        // Check for default ADR directory (indicates project root)
312        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        // Stop at git repository root
318        if current.join(".git").exists() {
319            break;
320        }
321
322        // Move to parent directory
323        match current.parent() {
324            Some(parent) => current = parent.to_path_buf(),
325            None => break,
326        }
327    }
328
329    Ok(None)
330}
331
332/// Load the global configuration file.
333fn 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
346/// Get the user's config directory.
347fn dirs_config_dir() -> Result<PathBuf> {
348    // Try XDG_CONFIG_HOME first, then fall back to ~/.config
349    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    // Windows fallback
358    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
367/// Apply environment variable overrides to a config.
368fn 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/// The mode of operation for the ADR tool.
375#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "lowercase")]
377pub enum ConfigMode {
378    /// Compatible with adr-tools (markdown-only, no frontmatter).
379    #[default]
380    Compatible,
381
382    /// Next-gen mode with YAML frontmatter and enhanced features.
383    #[serde(rename = "ng", alias = "nextgen")]
384    NextGen,
385}
386
387/// Template configuration.
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389#[serde(default)]
390pub struct TemplateConfig {
391    /// The default template format to use.
392    pub format: Option<String>,
393
394    /// The default template variant to use.
395    pub variant: Option<String>,
396
397    /// Path to a custom template file.
398    pub custom: Option<PathBuf>,
399}
400
401/// Generate command configuration.
402#[derive(Debug, Clone, Default, Serialize, Deserialize)]
403#[serde(default)]
404pub struct GenerateConfig {
405    /// Default prefix for TOC links (used by `adrs generate toc` if `--prefix` is not given).
406    pub toc_prefix: Option<String>,
407}
408
409/// Export command configuration.
410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
411#[serde(default)]
412pub struct ExportConfig {
413    /// Default base URL for source_uri in JSON export (used by `adrs export json` if `--base-url` is not given).
414    pub base_url: Option<String>,
415}
416
417/// Doctor command configuration.
418#[derive(Debug, Clone, Default, Serialize, Deserialize)]
419#[serde(default)]
420pub struct DoctorConfig {
421    /// Rule IDs or rule names to suppress (e.g. "ADR011", "adr-numbering-sequential").
422    /// Matched case-insensitively against both Issue.rule_id and Issue.rule_name.
423    pub ignore: Vec<String>,
424
425    /// When true, `adrs doctor` exits with status 1 if there are warnings, not just errors.
426    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    // ========== Default and Constants Tests ==========
436
437    #[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    // ========== Load Configuration Tests ==========
460
461    #[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        // Should use defaults for missing fields
607        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        // Create both config files
614        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        // Should prefer adrs.toml
619        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        // Create the default directory
626        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        // Empty directory - no config, no default dir
636
637        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        // Empty directory - would error with load()
645
646        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    // ========== Save Configuration Tests ==========
661
662    #[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        // Should not create adrs.toml
681        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        // Should not create .adr-dir
704        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    // ========== Helper Method Tests ==========
780
781    #[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    // ========== ConfigMode Tests ==========
808
809    #[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        // TOML requires enums to be serialized within a struct
819        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    // ========== TemplateConfig Tests ==========
850
851    #[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    // ========== Error Cases ==========
873
874    #[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        // Empty TOML should use defaults
889        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    // ========== Config Discovery Tests ==========
903
904    #[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        // Create a git repo structure
932        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        // Put config above git root (should not be found)
937        // This test verifies we stop at .git
938
939        let result = discover(&subdir);
940        // Should return defaults since no config found within git repo
941        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        // Create .git to stop search
970        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    // Serializes tests that mutate the process-global ADR_DIRECTORY env var.
978    // `std::env` is shared across cargo's parallel test threads, so without this
979    // lock these tests race and clobber each other's values (issue #241 follow-up).
980    // `unwrap_or_else(into_inner)` recovers the guard even if a prior test panicked
981    // while holding the lock (poisoned mutex).
982    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        // Test apply_env_overrides when ADR_DIRECTORY is not set.
988        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
989        // SAFETY: serialized by ENV_LOCK; env restored before returning.
990        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        // With no env var set, the config should remain at default
1002        assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
1003    }
1004
1005    // ========== apply_env_overrides positive cases (issue #241) ==========
1006    // Env vars are process-global; these tests serialize via ENV_LOCK and
1007    // save/restore the old value so they neither race nor leak state.
1008    // In Rust 2024 edition, set_var/remove_var require unsafe blocks.
1009
1010    #[test]
1011    fn test_apply_env_overrides_sets_adr_dir() {
1012        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1013        // Save old value
1014        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1015
1016        // SAFETY: single-threaded test; restoring env after test
1017        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        // Restore before asserting (so a panic does not leave env dirty)
1022        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        // Verify env override wins even when config already has a custom path
1036        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1037
1038        // SAFETY: single-threaded test; restoring env after test
1039        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        // Without ADR_DIRECTORY set, config is unchanged
1060        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1061
1062        // SAFETY: single-threaded test; restoring env after test
1063        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        // Test that ConfigSource can be compared
1084        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(); // has default adr_dir
1125
1126        base.merge(&other);
1127        // Should keep original since other has default
1128        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    // ========== no_edit Tests ==========
1144
1145    #[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        // merge: other.no_edit=false should NOT overwrite base.no_edit=true
1197        let mut base = Config {
1198            no_edit: true,
1199            ..Default::default()
1200        };
1201        let other = Config::default(); // no_edit = false
1202        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    // ========== Config Validation Tests ==========
1224
1225    #[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        // Invalid format strings are stored as-is in config; they only error
1252        // when parsed at ADR creation time. This is by design — the config
1253        // layer stores strings, the command layer validates them.
1254        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        // Serde's default behavior: unknown fields are silently ignored.
1303        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    // ========== Save/Load Roundtrip Tests ==========
1343
1344    #[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        // NextGen serializes as "ng" but should load back as NextGen
1378        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        // Verify the file contains "ng" not "nextgen"
1387        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1388        assert!(content.contains(r#"mode = "ng""#));
1389
1390        // Load it back
1391        let loaded = Config::load(temp.path()).unwrap();
1392        assert_eq!(loaded.mode, ConfigMode::NextGen);
1393    }
1394
1395    // ========== Config Merge Validation Tests ==========
1396
1397    #[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(); // all template fields are None
1440
1441        base.merge(&other);
1442
1443        // None values in other should NOT overwrite existing values
1444        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    // ========== GenerateConfig / toc_prefix Tests ==========
1450
1451    #[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(); // toc_prefix = None
1507
1508        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    // ========== ExportConfig / base_url Tests ==========
1538
1539    #[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(); // export.base_url = None
1601
1602        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    // ========== DoctorConfig / ignore, warnings_as_errors Tests ==========
1632
1633    #[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        // An empty ignore list in `other` must not clobber an existing base list.
1704        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(); // doctor.ignore is empty
1712
1713        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        // merge: other.warnings_as_errors=false should NOT overwrite base.warnings_as_errors=true
1724        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(); // warnings_as_errors = false
1732        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}