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    /// Template configuration.
36    #[serde(default)]
37    pub templates: TemplateConfig,
38}
39
40impl Default for Config {
41    fn default() -> Self {
42        Self {
43            adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
44            mode: ConfigMode::Compatible,
45            templates: TemplateConfig::default(),
46        }
47    }
48}
49
50impl Config {
51    /// Load configuration from the given directory.
52    ///
53    /// Searches for configuration in the following order:
54    /// 1. `adrs.toml` (new format)
55    /// 2. `.adr-dir` (legacy adr-tools format)
56    /// 3. Default configuration
57    pub fn load(root: &Path) -> Result<Self> {
58        // Try new config first
59        let config_path = root.join(CONFIG_FILE);
60        if config_path.exists() {
61            let content = std::fs::read_to_string(&config_path)?;
62            let config: Config = toml::from_str(&content)?;
63            if config.adr_dir.as_os_str().is_empty() {
64                return Err(Error::ConfigError(
65                    "adr_dir cannot be empty in adrs.toml".into(),
66                ));
67            }
68            return Ok(config);
69        }
70
71        // Try legacy .adr-dir file
72        let legacy_path = root.join(LEGACY_CONFIG_FILE);
73        if legacy_path.exists() {
74            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
75            if adr_dir.is_empty() {
76                return Err(Error::ConfigError(
77                    "ADR directory path is empty in .adr-dir file".into(),
78                ));
79            }
80            return Ok(Self {
81                adr_dir: PathBuf::from(adr_dir),
82                mode: ConfigMode::Compatible,
83                templates: TemplateConfig::default(),
84            });
85        }
86
87        // Check if default directory exists
88        let default_dir = root.join(DEFAULT_ADR_DIR);
89        if default_dir.exists() {
90            return Ok(Self::default());
91        }
92
93        Err(Error::AdrDirNotFound)
94    }
95
96    /// Load configuration, or return default if not found.
97    pub fn load_or_default(root: &Path) -> Self {
98        Self::load(root).unwrap_or_default()
99    }
100
101    /// Save configuration to the given directory.
102    pub fn save(&self, root: &Path) -> Result<()> {
103        match self.mode {
104            ConfigMode::Compatible => {
105                // Write legacy .adr-dir file
106                let path = root.join(LEGACY_CONFIG_FILE);
107                std::fs::write(&path, self.adr_dir.display().to_string())?;
108            }
109            ConfigMode::NextGen => {
110                // Write adrs.toml
111                let path = root.join(CONFIG_FILE);
112                let content =
113                    toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
114                std::fs::write(&path, content)?;
115            }
116        }
117        Ok(())
118    }
119
120    /// Returns the full path to the ADR directory.
121    pub fn adr_path(&self, root: &Path) -> PathBuf {
122        root.join(&self.adr_dir)
123    }
124
125    /// Returns true if running in next-gen mode.
126    pub fn is_next_gen(&self) -> bool {
127        matches!(self.mode, ConfigMode::NextGen)
128    }
129
130    /// Merge another config into this one (other takes precedence for set values).
131    pub fn merge(&mut self, other: &Config) {
132        // adr_dir: use other if it differs from default
133        if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
134            self.adr_dir = other.adr_dir.clone();
135        }
136        // mode: other takes precedence
137        self.mode = other.mode;
138        // templates: merge
139        if other.templates.format.is_some() {
140            self.templates.format = other.templates.format.clone();
141        }
142        if other.templates.variant.is_some() {
143            self.templates.variant = other.templates.variant.clone();
144        }
145        if other.templates.custom.is_some() {
146            self.templates.custom = other.templates.custom.clone();
147        }
148    }
149}
150
151/// Result of discovering configuration.
152#[derive(Debug, Clone)]
153pub struct DiscoveredConfig {
154    /// The resolved configuration.
155    pub config: Config,
156    /// The project root directory (where config was found).
157    pub root: PathBuf,
158    /// Where the config was loaded from.
159    pub source: ConfigSource,
160}
161
162/// Where the configuration was loaded from.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum ConfigSource {
165    /// Loaded from project config file.
166    Project(PathBuf),
167    /// Loaded from global config file.
168    Global(PathBuf),
169    /// Loaded from environment variable.
170    Environment,
171    /// Using defaults (no config found).
172    Default,
173}
174
175/// Discover configuration by searching up the directory tree.
176///
177/// Search order:
178/// 1. Environment variable `ADRS_CONFIG` (explicit config path)
179/// 2. Search upward from `start_dir` for `.adr-dir` or `adrs.toml`
180/// 3. Global config at `~/.config/adrs/config.toml`
181/// 4. Default configuration
182///
183/// Environment variable `ADR_DIRECTORY` overrides the ADR directory.
184pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
185    // Check for explicit config path from environment
186    if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
187        let path = PathBuf::from(&config_path);
188        if path.exists() {
189            let content = std::fs::read_to_string(&path)?;
190            let mut config: Config = toml::from_str(&content)?;
191            apply_env_overrides(&mut config);
192            return Ok(DiscoveredConfig {
193                config,
194                root: path
195                    .parent()
196                    .map(|p| p.to_path_buf())
197                    .unwrap_or_else(|| start_dir.to_path_buf()),
198                source: ConfigSource::Environment,
199            });
200        }
201    }
202
203    // Search upward for project config
204    if let Some((root, config, source)) = search_upward(start_dir)? {
205        let mut config = config;
206        apply_env_overrides(&mut config);
207        return Ok(DiscoveredConfig {
208            config,
209            root,
210            source,
211        });
212    }
213
214    // Try global config
215    if let Some((config, path)) = load_global_config()? {
216        let mut config = config;
217        apply_env_overrides(&mut config);
218        return Ok(DiscoveredConfig {
219            config,
220            root: start_dir.to_path_buf(),
221            source: ConfigSource::Global(path),
222        });
223    }
224
225    // Use defaults
226    let mut config = Config::default();
227    apply_env_overrides(&mut config);
228    Ok(DiscoveredConfig {
229        config,
230        root: start_dir.to_path_buf(),
231        source: ConfigSource::Default,
232    })
233}
234
235/// Search upward from the given directory for a config file.
236fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
237    let mut current = start_dir.to_path_buf();
238
239    loop {
240        // Check for adrs.toml first
241        let config_path = current.join(CONFIG_FILE);
242        if config_path.exists() {
243            let content = std::fs::read_to_string(&config_path)?;
244            let config: Config = toml::from_str(&content)?;
245            return Ok(Some((current, config, ConfigSource::Project(config_path))));
246        }
247
248        // Check for .adr-dir
249        let legacy_path = current.join(LEGACY_CONFIG_FILE);
250        if legacy_path.exists() {
251            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
252            let config = Config {
253                adr_dir: PathBuf::from(adr_dir),
254                mode: ConfigMode::Compatible,
255                templates: TemplateConfig::default(),
256            };
257            return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
258        }
259
260        // Check for default ADR directory (indicates project root)
261        let default_dir = current.join(DEFAULT_ADR_DIR);
262        if default_dir.exists() {
263            return Ok(Some((current, Config::default(), ConfigSource::Default)));
264        }
265
266        // Stop at git repository root
267        if current.join(".git").exists() {
268            break;
269        }
270
271        // Move to parent directory
272        match current.parent() {
273            Some(parent) => current = parent.to_path_buf(),
274            None => break,
275        }
276    }
277
278    Ok(None)
279}
280
281/// Load the global configuration file.
282fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
283    let config_dir = dirs_config_dir()?;
284    let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
285
286    if global_path.exists() {
287        let content = std::fs::read_to_string(&global_path)?;
288        let config: Config = toml::from_str(&content)?;
289        return Ok(Some((config, global_path)));
290    }
291
292    Ok(None)
293}
294
295/// Get the user's config directory.
296fn dirs_config_dir() -> Result<PathBuf> {
297    // Try XDG_CONFIG_HOME first, then fall back to ~/.config
298    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
299        return Ok(PathBuf::from(xdg));
300    }
301
302    if let Ok(home) = std::env::var("HOME") {
303        return Ok(PathBuf::from(home).join(".config"));
304    }
305
306    // Windows fallback
307    if let Ok(appdata) = std::env::var("APPDATA") {
308        return Ok(PathBuf::from(appdata));
309    }
310
311    Err(Error::ConfigError(
312        "Could not determine config directory".into(),
313    ))
314}
315
316/// Apply environment variable overrides to a config.
317fn apply_env_overrides(config: &mut Config) {
318    if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
319        config.adr_dir = PathBuf::from(adr_dir);
320    }
321}
322
323/// The mode of operation for the ADR tool.
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum ConfigMode {
327    /// Compatible with adr-tools (markdown-only, no frontmatter).
328    #[default]
329    Compatible,
330
331    /// Next-gen mode with YAML frontmatter and enhanced features.
332    #[serde(rename = "ng", alias = "nextgen")]
333    NextGen,
334}
335
336/// Template configuration.
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338#[serde(default)]
339pub struct TemplateConfig {
340    /// The default template format to use.
341    pub format: Option<String>,
342
343    /// The default template variant to use.
344    pub variant: Option<String>,
345
346    /// Path to a custom template file.
347    pub custom: Option<PathBuf>,
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use tempfile::TempDir;
354    use test_case::test_case;
355
356    // ========== Default and Constants Tests ==========
357
358    #[test]
359    fn test_default_config() {
360        let config = Config::default();
361        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
362        assert_eq!(config.mode, ConfigMode::Compatible);
363        assert!(config.templates.format.is_none());
364        assert!(config.templates.custom.is_none());
365    }
366
367    #[test]
368    fn test_constants() {
369        assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
370        assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
371        assert_eq!(CONFIG_FILE, "adrs.toml");
372    }
373
374    #[test]
375    fn test_config_mode_default() {
376        assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
377    }
378
379    // ========== Load Configuration Tests ==========
380
381    #[test]
382    fn test_load_legacy_config() {
383        let temp = TempDir::new().unwrap();
384        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
385
386        let config = Config::load(temp.path()).unwrap();
387        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
388        assert_eq!(config.mode, ConfigMode::Compatible);
389    }
390
391    #[test]
392    fn test_load_legacy_config_with_whitespace() {
393        let temp = TempDir::new().unwrap();
394        std::fs::write(temp.path().join(".adr-dir"), "  decisions  \n").unwrap();
395
396        let config = Config::load(temp.path()).unwrap();
397        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
398    }
399
400    #[test]
401    fn test_load_legacy_config_nested_path() {
402        let temp = TempDir::new().unwrap();
403        std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
404
405        let config = Config::load(temp.path()).unwrap();
406        assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
407    }
408
409    #[test]
410    fn test_load_new_config() {
411        let temp = TempDir::new().unwrap();
412        std::fs::write(
413            temp.path().join("adrs.toml"),
414            r#"
415adr_dir = "docs/decisions"
416mode = "ng"
417"#,
418        )
419        .unwrap();
420
421        let config = Config::load(temp.path()).unwrap();
422        assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
423        assert_eq!(config.mode, ConfigMode::NextGen);
424    }
425
426    #[test]
427    fn test_load_new_config_compatible_mode() {
428        let temp = TempDir::new().unwrap();
429        std::fs::write(
430            temp.path().join("adrs.toml"),
431            r#"
432adr_dir = "doc/adr"
433mode = "compatible"
434"#,
435        )
436        .unwrap();
437
438        let config = Config::load(temp.path()).unwrap();
439        assert_eq!(config.mode, ConfigMode::Compatible);
440    }
441
442    #[test]
443    fn test_load_new_config_with_templates() {
444        let temp = TempDir::new().unwrap();
445        std::fs::write(
446            temp.path().join("adrs.toml"),
447            r#"
448adr_dir = "decisions"
449mode = "ng"
450
451[templates]
452format = "markdown"
453custom = "templates/adr.md"
454"#,
455        )
456        .unwrap();
457
458        let config = Config::load(temp.path()).unwrap();
459        assert_eq!(config.templates.format, Some("markdown".to_string()));
460        assert_eq!(
461            config.templates.custom,
462            Some(PathBuf::from("templates/adr.md"))
463        );
464    }
465
466    #[test]
467    fn test_load_new_config_with_template_variant() {
468        let temp = TempDir::new().unwrap();
469        std::fs::write(
470            temp.path().join("adrs.toml"),
471            r#"
472adr_dir = "decisions"
473mode = "ng"
474
475[templates]
476format = "madr"
477variant = "minimal"
478"#,
479        )
480        .unwrap();
481
482        let config = Config::load(temp.path()).unwrap();
483        assert_eq!(config.templates.format, Some("madr".to_string()));
484        assert_eq!(config.templates.variant, Some("minimal".to_string()));
485    }
486
487    #[test]
488    fn test_load_new_config_with_nextgen_alias() {
489        let temp = TempDir::new().unwrap();
490        std::fs::write(
491            temp.path().join("adrs.toml"),
492            r#"
493adr_dir = "decisions"
494mode = "nextgen"
495"#,
496        )
497        .unwrap();
498
499        let config = Config::load(temp.path()).unwrap();
500        assert_eq!(config.mode, ConfigMode::NextGen);
501    }
502
503    #[test]
504    fn test_load_new_config_minimal() {
505        let temp = TempDir::new().unwrap();
506        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
507
508        let config = Config::load(temp.path()).unwrap();
509        assert_eq!(config.adr_dir, PathBuf::from("adrs"));
510        // Should use defaults for missing fields
511        assert_eq!(config.mode, ConfigMode::Compatible);
512    }
513
514    #[test]
515    fn test_load_prefers_new_config_over_legacy() {
516        let temp = TempDir::new().unwrap();
517        // Create both config files
518        std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
519        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
520
521        let config = Config::load(temp.path()).unwrap();
522        // Should prefer adrs.toml
523        assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
524    }
525
526    #[test]
527    fn test_load_default_dir_exists() {
528        let temp = TempDir::new().unwrap();
529        // Create the default directory
530        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
531
532        let config = Config::load(temp.path()).unwrap();
533        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
534    }
535
536    #[test]
537    fn test_load_no_config_no_default_dir() {
538        let temp = TempDir::new().unwrap();
539        // Empty directory - no config, no default dir
540
541        let result = Config::load(temp.path());
542        assert!(result.is_err());
543    }
544
545    #[test]
546    fn test_load_or_default_returns_default_on_error() {
547        let temp = TempDir::new().unwrap();
548        // Empty directory - would error with load()
549
550        let config = Config::load_or_default(temp.path());
551        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
552        assert_eq!(config.mode, ConfigMode::Compatible);
553    }
554
555    #[test]
556    fn test_load_or_default_returns_config_when_exists() {
557        let temp = TempDir::new().unwrap();
558        std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
559
560        let config = Config::load_or_default(temp.path());
561        assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
562    }
563
564    // ========== Save Configuration Tests ==========
565
566    #[test]
567    fn test_save_legacy_config() {
568        let temp = TempDir::new().unwrap();
569        let config = Config {
570            adr_dir: PathBuf::from("my/adrs"),
571            mode: ConfigMode::Compatible,
572            templates: TemplateConfig::default(),
573        };
574
575        config.save(temp.path()).unwrap();
576
577        let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
578        assert_eq!(content, "my/adrs");
579        // Should not create adrs.toml
580        assert!(!temp.path().join("adrs.toml").exists());
581    }
582
583    #[test]
584    fn test_save_new_config() {
585        let temp = TempDir::new().unwrap();
586        let config = Config {
587            adr_dir: PathBuf::from("docs/decisions"),
588            mode: ConfigMode::NextGen,
589            templates: TemplateConfig::default(),
590        };
591
592        config.save(temp.path()).unwrap();
593
594        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
595        assert!(content.contains("docs/decisions"));
596        assert!(content.contains("ng"));
597        // Should not create .adr-dir
598        assert!(!temp.path().join(".adr-dir").exists());
599    }
600
601    #[test]
602    fn test_save_new_config_with_templates() {
603        let temp = TempDir::new().unwrap();
604        let config = Config {
605            adr_dir: PathBuf::from("decisions"),
606            mode: ConfigMode::NextGen,
607            templates: TemplateConfig {
608                format: Some("custom".to_string()),
609                variant: None,
610                custom: Some(PathBuf::from("my-template.md")),
611            },
612        };
613
614        config.save(temp.path()).unwrap();
615
616        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
617        assert!(content.contains("custom"));
618        assert!(content.contains("my-template.md"));
619    }
620
621    #[test]
622    fn test_save_and_load_roundtrip_compatible() {
623        let temp = TempDir::new().unwrap();
624        let original = Config {
625            adr_dir: PathBuf::from("architecture/decisions"),
626            mode: ConfigMode::Compatible,
627            templates: TemplateConfig::default(),
628        };
629
630        original.save(temp.path()).unwrap();
631        let loaded = Config::load(temp.path()).unwrap();
632
633        assert_eq!(loaded.adr_dir, original.adr_dir);
634        assert_eq!(loaded.mode, ConfigMode::Compatible);
635    }
636
637    #[test]
638    fn test_save_and_load_roundtrip_nextgen() {
639        let temp = TempDir::new().unwrap();
640        let original = Config {
641            adr_dir: PathBuf::from("docs/adr"),
642            mode: ConfigMode::NextGen,
643            templates: TemplateConfig {
644                format: Some("markdown".to_string()),
645                variant: None,
646                custom: None,
647            },
648        };
649
650        original.save(temp.path()).unwrap();
651        let loaded = Config::load(temp.path()).unwrap();
652
653        assert_eq!(loaded.adr_dir, original.adr_dir);
654        assert_eq!(loaded.mode, ConfigMode::NextGen);
655        assert_eq!(loaded.templates.format, Some("markdown".to_string()));
656    }
657
658    // ========== Helper Method Tests ==========
659
660    #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
661    #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
662    #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
663    fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
664        let config = Config {
665            adr_dir: PathBuf::from(adr_dir),
666            ..Default::default()
667        };
668        config.adr_path(Path::new(root))
669    }
670
671    #[test]
672    fn test_is_next_gen() {
673        let compatible = Config {
674            mode: ConfigMode::Compatible,
675            ..Default::default()
676        };
677        assert!(!compatible.is_next_gen());
678
679        let nextgen = Config {
680            mode: ConfigMode::NextGen,
681            ..Default::default()
682        };
683        assert!(nextgen.is_next_gen());
684    }
685
686    // ========== ConfigMode Tests ==========
687
688    #[test]
689    fn test_config_mode_equality() {
690        assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
691        assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
692        assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
693    }
694
695    #[test]
696    fn test_config_mode_serialization_in_config() {
697        // TOML requires enums to be serialized within a struct
698        let config = Config {
699            mode: ConfigMode::Compatible,
700            ..Default::default()
701        };
702        let toml = toml::to_string(&config).unwrap();
703        assert!(toml.contains("mode = \"compatible\""));
704
705        let config = Config {
706            mode: ConfigMode::NextGen,
707            ..Default::default()
708        };
709        let toml = toml::to_string(&config).unwrap();
710        assert!(toml.contains("mode = \"ng\""));
711    }
712
713    #[test]
714    fn test_config_mode_deserialization_in_config() {
715        let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
716        assert_eq!(config.mode, ConfigMode::Compatible);
717
718        let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
719        assert_eq!(config.mode, ConfigMode::NextGen);
720    }
721
722    #[test]
723    fn test_config_mode_deserialization_nextgen_alias() {
724        let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
725        assert_eq!(config.mode, ConfigMode::NextGen);
726    }
727
728    // ========== TemplateConfig Tests ==========
729
730    #[test]
731    fn test_template_config_default() {
732        let config = TemplateConfig::default();
733        assert!(config.format.is_none());
734        assert!(config.variant.is_none());
735        assert!(config.custom.is_none());
736    }
737
738    #[test]
739    fn test_template_config_serialization() {
740        let config = TemplateConfig {
741            format: Some("nygard".to_string()),
742            variant: None,
743            custom: Some(PathBuf::from("templates/custom.md")),
744        };
745
746        let toml = toml::to_string(&config).unwrap();
747        assert!(toml.contains("nygard"));
748        assert!(toml.contains("templates/custom.md"));
749    }
750
751    // ========== Error Cases ==========
752
753    #[test]
754    fn test_load_invalid_toml() {
755        let temp = TempDir::new().unwrap();
756        std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
757
758        let result = Config::load(temp.path());
759        assert!(result.is_err());
760    }
761
762    #[test]
763    fn test_load_empty_toml() {
764        let temp = TempDir::new().unwrap();
765        std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
766
767        // Empty TOML should use defaults
768        let config = Config::load(temp.path()).unwrap();
769        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
770    }
771
772    #[test]
773    fn test_load_empty_adr_dir_file() {
774        let temp = TempDir::new().unwrap();
775        std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
776
777        let result = Config::load(temp.path());
778        assert!(result.is_err(), "Empty .adr-dir should produce an error");
779    }
780
781    // ========== Config Discovery Tests ==========
782
783    #[test]
784    fn test_discover_finds_config_in_current_dir() {
785        let temp = TempDir::new().unwrap();
786        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
787
788        let discovered = discover(temp.path()).unwrap();
789        assert_eq!(discovered.root, temp.path());
790        assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
791        assert!(matches!(discovered.source, ConfigSource::Project(_)));
792    }
793
794    #[test]
795    fn test_discover_finds_config_in_parent_dir() {
796        let temp = TempDir::new().unwrap();
797        let subdir = temp.path().join("src").join("lib");
798        std::fs::create_dir_all(&subdir).unwrap();
799        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
800
801        let discovered = discover(&subdir).unwrap();
802        assert_eq!(discovered.root, temp.path());
803        assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
804    }
805
806    #[test]
807    fn test_discover_stops_at_git_root() {
808        let temp = TempDir::new().unwrap();
809
810        // Create a git repo structure
811        std::fs::create_dir(temp.path().join(".git")).unwrap();
812        let subdir = temp.path().join("src");
813        std::fs::create_dir(&subdir).unwrap();
814
815        // Put config above git root (should not be found)
816        // This test verifies we stop at .git
817
818        let result = discover(&subdir);
819        // Should return defaults since no config found within git repo
820        assert!(result.is_ok());
821        let discovered = result.unwrap();
822        assert!(matches!(discovered.source, ConfigSource::Default));
823    }
824
825    #[test]
826    fn test_discover_prefers_adrs_toml_over_adr_dir() {
827        let temp = TempDir::new().unwrap();
828        std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
829        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
830
831        let discovered = discover(temp.path()).unwrap();
832        assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
833    }
834
835    #[test]
836    fn test_discover_finds_default_adr_dir() {
837        let temp = TempDir::new().unwrap();
838        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
839
840        let discovered = discover(temp.path()).unwrap();
841        assert_eq!(discovered.root, temp.path());
842        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
843    }
844
845    #[test]
846    fn test_discover_returns_defaults_when_nothing_found() {
847        let temp = TempDir::new().unwrap();
848        // Create .git to stop search
849        std::fs::create_dir(temp.path().join(".git")).unwrap();
850
851        let discovered = discover(temp.path()).unwrap();
852        assert!(matches!(discovered.source, ConfigSource::Default));
853        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
854    }
855
856    // Serializes tests that mutate the process-global ADR_DIRECTORY env var.
857    // `std::env` is shared across cargo's parallel test threads, so without this
858    // lock these tests race and clobber each other's values (issue #241 follow-up).
859    // `unwrap_or_else(into_inner)` recovers the guard even if a prior test panicked
860    // while holding the lock (poisoned mutex).
861    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
862
863    #[test]
864    fn test_apply_env_overrides() {
865        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
866        // Test apply_env_overrides when ADR_DIRECTORY is not set.
867        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
868        // SAFETY: serialized by ENV_LOCK; env restored before returning.
869        unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
870
871        let mut config = Config::default();
872        apply_env_overrides(&mut config);
873
874        unsafe {
875            if let Some(v) = old {
876                std::env::set_var(ENV_ADR_DIRECTORY, v);
877            }
878        }
879
880        // With no env var set, the config should remain at default
881        assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
882    }
883
884    // ========== apply_env_overrides positive cases (issue #241) ==========
885    // Env vars are process-global; these tests serialize via ENV_LOCK and
886    // save/restore the old value so they neither race nor leak state.
887    // In Rust 2024 edition, set_var/remove_var require unsafe blocks.
888
889    #[test]
890    fn test_apply_env_overrides_sets_adr_dir() {
891        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
892        // Save old value
893        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
894
895        // SAFETY: single-threaded test; restoring env after test
896        unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
897        let mut config = Config::default();
898        apply_env_overrides(&mut config);
899
900        // Restore before asserting (so a panic does not leave env dirty)
901        unsafe {
902            match old {
903                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
904                None => std::env::remove_var(ENV_ADR_DIRECTORY),
905            }
906        }
907
908        assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
909    }
910
911    #[test]
912    fn test_apply_env_overrides_overrides_non_default_adr_dir() {
913        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
914        // Verify env override wins even when config already has a custom path
915        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
916
917        // SAFETY: single-threaded test; restoring env after test
918        unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
919        let mut config = Config {
920            adr_dir: PathBuf::from("config_dir"),
921            ..Default::default()
922        };
923        apply_env_overrides(&mut config);
924
925        unsafe {
926            match old {
927                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
928                None => std::env::remove_var(ENV_ADR_DIRECTORY),
929            }
930        }
931
932        assert_eq!(config.adr_dir, PathBuf::from("env_override"));
933    }
934
935    #[test]
936    fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
937        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
938        // Without ADR_DIRECTORY set, config is unchanged
939        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
940
941        // SAFETY: single-threaded test; restoring env after test
942        unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
943
944        let mut config = Config {
945            adr_dir: PathBuf::from("original/path"),
946            ..Default::default()
947        };
948        apply_env_overrides(&mut config);
949
950        unsafe {
951            match old {
952                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
953                None => std::env::remove_var(ENV_ADR_DIRECTORY),
954            }
955        }
956
957        assert_eq!(config.adr_dir, PathBuf::from("original/path"));
958    }
959
960    #[test]
961    fn test_config_source_variants() {
962        // Test that ConfigSource can be compared
963        let project = ConfigSource::Project(PathBuf::from("test"));
964        let global = ConfigSource::Global(PathBuf::from("test"));
965        let env = ConfigSource::Environment;
966        let default = ConfigSource::Default;
967
968        assert_ne!(project, global);
969        assert_ne!(env, default);
970        assert_eq!(default, ConfigSource::Default);
971    }
972
973    #[test]
974    fn test_config_merge() {
975        let mut base = Config::default();
976        let other = Config {
977            adr_dir: PathBuf::from("custom"),
978            mode: ConfigMode::NextGen,
979            templates: TemplateConfig {
980                format: Some("madr".to_string()),
981                variant: None,
982                custom: None,
983            },
984        };
985
986        base.merge(&other);
987        assert_eq!(base.adr_dir, PathBuf::from("custom"));
988        assert_eq!(base.mode, ConfigMode::NextGen);
989        assert_eq!(base.templates.format, Some("madr".to_string()));
990    }
991
992    #[test]
993    fn test_config_merge_preserves_default_adr_dir() {
994        let mut base = Config {
995            adr_dir: PathBuf::from("original"),
996            ..Default::default()
997        };
998        let other = Config::default(); // has default adr_dir
999
1000        base.merge(&other);
1001        // Should keep original since other has default
1002        assert_eq!(base.adr_dir, PathBuf::from("original"));
1003    }
1004
1005    // ========== Config Validation Tests ==========
1006
1007    #[test]
1008    fn test_load_empty_adr_dir_in_toml() {
1009        let temp = TempDir::new().unwrap();
1010        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
1011
1012        let result = Config::load(temp.path());
1013        assert!(
1014            result.is_err(),
1015            "Empty adr_dir in TOML should produce an error"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_load_whitespace_only_adr_dir_file() {
1021        let temp = TempDir::new().unwrap();
1022        std::fs::write(temp.path().join(".adr-dir"), "   \n  ").unwrap();
1023
1024        let result = Config::load(temp.path());
1025        assert!(
1026            result.is_err(),
1027            "Whitespace-only .adr-dir should produce an error"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_invalid_format_string_accepted_in_toml() {
1033        // Invalid format strings are stored as-is in config; they only error
1034        // when parsed at ADR creation time. This is by design — the config
1035        // layer stores strings, the command layer validates them.
1036        let temp = TempDir::new().unwrap();
1037        std::fs::write(
1038            temp.path().join("adrs.toml"),
1039            r#"
1040adr_dir = "doc/adr"
1041
1042[templates]
1043format = "nonexistent"
1044"#,
1045        )
1046        .unwrap();
1047
1048        let config = Config::load(temp.path()).unwrap();
1049        assert_eq!(config.templates.format, Some("nonexistent".to_string()));
1050    }
1051
1052    #[test]
1053    fn test_invalid_variant_string_accepted_in_toml() {
1054        let temp = TempDir::new().unwrap();
1055        std::fs::write(
1056            temp.path().join("adrs.toml"),
1057            r#"
1058adr_dir = "doc/adr"
1059
1060[templates]
1061variant = "bogus"
1062"#,
1063        )
1064        .unwrap();
1065
1066        let config = Config::load(temp.path()).unwrap();
1067        assert_eq!(config.templates.variant, Some("bogus".to_string()));
1068    }
1069
1070    #[test]
1071    fn test_invalid_mode_string_rejected() {
1072        let temp = TempDir::new().unwrap();
1073        std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
1074
1075        let result = Config::load(temp.path());
1076        assert!(
1077            result.is_err(),
1078            "Invalid mode should produce a TOML parse error"
1079        );
1080    }
1081
1082    #[test]
1083    fn test_unknown_toml_fields_accepted() {
1084        // Serde's default behavior: unknown fields are silently ignored.
1085        let temp = TempDir::new().unwrap();
1086        std::fs::write(
1087            temp.path().join("adrs.toml"),
1088            r#"
1089adr_dir = "doc/adr"
1090unknown_field = "hello"
1091
1092[templates]
1093also_unknown = true
1094"#,
1095        )
1096        .unwrap();
1097
1098        let config = Config::load(temp.path()).unwrap();
1099        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1100    }
1101
1102    #[test]
1103    fn test_custom_template_path_in_config() {
1104        let temp = TempDir::new().unwrap();
1105        std::fs::write(
1106            temp.path().join("adrs.toml"),
1107            r#"
1108adr_dir = "doc/adr"
1109mode = "ng"
1110
1111[templates]
1112custom = "templates/my-adr.md"
1113"#,
1114        )
1115        .unwrap();
1116
1117        let config = Config::load(temp.path()).unwrap();
1118        assert_eq!(
1119            config.templates.custom,
1120            Some(PathBuf::from("templates/my-adr.md"))
1121        );
1122    }
1123
1124    // ========== Save/Load Roundtrip Tests ==========
1125
1126    #[test]
1127    fn test_save_and_load_roundtrip_nextgen_with_templates() {
1128        let temp = TempDir::new().unwrap();
1129        let original = Config {
1130            adr_dir: PathBuf::from("docs/decisions"),
1131            mode: ConfigMode::NextGen,
1132            templates: TemplateConfig {
1133                format: Some("madr".to_string()),
1134                variant: Some("minimal".to_string()),
1135                custom: Some(PathBuf::from("templates/custom.md")),
1136            },
1137        };
1138
1139        original.save(temp.path()).unwrap();
1140        let loaded = Config::load(temp.path()).unwrap();
1141
1142        assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1143        assert_eq!(loaded.mode, ConfigMode::NextGen);
1144        assert_eq!(loaded.templates.format, Some("madr".to_string()));
1145        assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1146        assert_eq!(
1147            loaded.templates.custom,
1148            Some(PathBuf::from("templates/custom.md"))
1149        );
1150    }
1151
1152    #[test]
1153    fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1154        // NextGen serializes as "ng" but should load back as NextGen
1155        let temp = TempDir::new().unwrap();
1156        let original = Config {
1157            mode: ConfigMode::NextGen,
1158            ..Default::default()
1159        };
1160
1161        original.save(temp.path()).unwrap();
1162
1163        // Verify the file contains "ng" not "nextgen"
1164        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1165        assert!(content.contains(r#"mode = "ng""#));
1166
1167        // Load it back
1168        let loaded = Config::load(temp.path()).unwrap();
1169        assert_eq!(loaded.mode, ConfigMode::NextGen);
1170    }
1171
1172    // ========== Config Merge Validation Tests ==========
1173
1174    #[test]
1175    fn test_config_merge_variant_field() {
1176        let mut base = Config::default();
1177        let other = Config {
1178            templates: TemplateConfig {
1179                format: None,
1180                variant: Some("minimal".to_string()),
1181                custom: None,
1182            },
1183            ..Default::default()
1184        };
1185
1186        base.merge(&other);
1187        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1188    }
1189
1190    #[test]
1191    fn test_config_merge_custom_field() {
1192        let mut base = Config::default();
1193        let other = Config {
1194            templates: TemplateConfig {
1195                format: None,
1196                variant: None,
1197                custom: Some(PathBuf::from("my-template.md")),
1198            },
1199            ..Default::default()
1200        };
1201
1202        base.merge(&other);
1203        assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1204    }
1205
1206    #[test]
1207    fn test_config_merge_does_not_overwrite_with_none() {
1208        let mut base = Config {
1209            templates: TemplateConfig {
1210                format: Some("madr".to_string()),
1211                variant: Some("minimal".to_string()),
1212                custom: Some(PathBuf::from("template.md")),
1213            },
1214            ..Default::default()
1215        };
1216        let other = Config::default(); // all template fields are None
1217
1218        base.merge(&other);
1219
1220        // None values in other should NOT overwrite existing values
1221        assert_eq!(base.templates.format, Some("madr".to_string()));
1222        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1223        assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1224    }
1225}