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