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    #[test]
857    fn test_apply_env_overrides() {
858        // Test apply_env_overrides function directly without modifying the environment.
859        // The function reads env vars, so we test that it doesn't panic and returns
860        // when no env vars are set.
861        let mut config = Config::default();
862        apply_env_overrides(&mut config);
863        // With no env vars set, the config should remain at default
864        assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
865    }
866
867    #[test]
868    fn test_config_source_variants() {
869        // Test that ConfigSource can be compared
870        let project = ConfigSource::Project(PathBuf::from("test"));
871        let global = ConfigSource::Global(PathBuf::from("test"));
872        let env = ConfigSource::Environment;
873        let default = ConfigSource::Default;
874
875        assert_ne!(project, global);
876        assert_ne!(env, default);
877        assert_eq!(default, ConfigSource::Default);
878    }
879
880    #[test]
881    fn test_config_merge() {
882        let mut base = Config::default();
883        let other = Config {
884            adr_dir: PathBuf::from("custom"),
885            mode: ConfigMode::NextGen,
886            templates: TemplateConfig {
887                format: Some("madr".to_string()),
888                variant: None,
889                custom: None,
890            },
891        };
892
893        base.merge(&other);
894        assert_eq!(base.adr_dir, PathBuf::from("custom"));
895        assert_eq!(base.mode, ConfigMode::NextGen);
896        assert_eq!(base.templates.format, Some("madr".to_string()));
897    }
898
899    #[test]
900    fn test_config_merge_preserves_default_adr_dir() {
901        let mut base = Config {
902            adr_dir: PathBuf::from("original"),
903            ..Default::default()
904        };
905        let other = Config::default(); // has default adr_dir
906
907        base.merge(&other);
908        // Should keep original since other has default
909        assert_eq!(base.adr_dir, PathBuf::from("original"));
910    }
911
912    // ========== Config Validation Tests ==========
913
914    #[test]
915    fn test_load_empty_adr_dir_in_toml() {
916        let temp = TempDir::new().unwrap();
917        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
918
919        let result = Config::load(temp.path());
920        assert!(
921            result.is_err(),
922            "Empty adr_dir in TOML should produce an error"
923        );
924    }
925
926    #[test]
927    fn test_load_whitespace_only_adr_dir_file() {
928        let temp = TempDir::new().unwrap();
929        std::fs::write(temp.path().join(".adr-dir"), "   \n  ").unwrap();
930
931        let result = Config::load(temp.path());
932        assert!(
933            result.is_err(),
934            "Whitespace-only .adr-dir should produce an error"
935        );
936    }
937
938    #[test]
939    fn test_invalid_format_string_accepted_in_toml() {
940        // Invalid format strings are stored as-is in config; they only error
941        // when parsed at ADR creation time. This is by design — the config
942        // layer stores strings, the command layer validates them.
943        let temp = TempDir::new().unwrap();
944        std::fs::write(
945            temp.path().join("adrs.toml"),
946            r#"
947adr_dir = "doc/adr"
948
949[templates]
950format = "nonexistent"
951"#,
952        )
953        .unwrap();
954
955        let config = Config::load(temp.path()).unwrap();
956        assert_eq!(config.templates.format, Some("nonexistent".to_string()));
957    }
958
959    #[test]
960    fn test_invalid_variant_string_accepted_in_toml() {
961        let temp = TempDir::new().unwrap();
962        std::fs::write(
963            temp.path().join("adrs.toml"),
964            r#"
965adr_dir = "doc/adr"
966
967[templates]
968variant = "bogus"
969"#,
970        )
971        .unwrap();
972
973        let config = Config::load(temp.path()).unwrap();
974        assert_eq!(config.templates.variant, Some("bogus".to_string()));
975    }
976
977    #[test]
978    fn test_invalid_mode_string_rejected() {
979        let temp = TempDir::new().unwrap();
980        std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
981
982        let result = Config::load(temp.path());
983        assert!(
984            result.is_err(),
985            "Invalid mode should produce a TOML parse error"
986        );
987    }
988
989    #[test]
990    fn test_unknown_toml_fields_accepted() {
991        // Serde's default behavior: unknown fields are silently ignored.
992        let temp = TempDir::new().unwrap();
993        std::fs::write(
994            temp.path().join("adrs.toml"),
995            r#"
996adr_dir = "doc/adr"
997unknown_field = "hello"
998
999[templates]
1000also_unknown = true
1001"#,
1002        )
1003        .unwrap();
1004
1005        let config = Config::load(temp.path()).unwrap();
1006        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1007    }
1008
1009    #[test]
1010    fn test_custom_template_path_in_config() {
1011        let temp = TempDir::new().unwrap();
1012        std::fs::write(
1013            temp.path().join("adrs.toml"),
1014            r#"
1015adr_dir = "doc/adr"
1016mode = "ng"
1017
1018[templates]
1019custom = "templates/my-adr.md"
1020"#,
1021        )
1022        .unwrap();
1023
1024        let config = Config::load(temp.path()).unwrap();
1025        assert_eq!(
1026            config.templates.custom,
1027            Some(PathBuf::from("templates/my-adr.md"))
1028        );
1029    }
1030
1031    // ========== Save/Load Roundtrip Tests ==========
1032
1033    #[test]
1034    fn test_save_and_load_roundtrip_nextgen_with_templates() {
1035        let temp = TempDir::new().unwrap();
1036        let original = Config {
1037            adr_dir: PathBuf::from("docs/decisions"),
1038            mode: ConfigMode::NextGen,
1039            templates: TemplateConfig {
1040                format: Some("madr".to_string()),
1041                variant: Some("minimal".to_string()),
1042                custom: Some(PathBuf::from("templates/custom.md")),
1043            },
1044        };
1045
1046        original.save(temp.path()).unwrap();
1047        let loaded = Config::load(temp.path()).unwrap();
1048
1049        assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1050        assert_eq!(loaded.mode, ConfigMode::NextGen);
1051        assert_eq!(loaded.templates.format, Some("madr".to_string()));
1052        assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1053        assert_eq!(
1054            loaded.templates.custom,
1055            Some(PathBuf::from("templates/custom.md"))
1056        );
1057    }
1058
1059    #[test]
1060    fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1061        // NextGen serializes as "ng" but should load back as NextGen
1062        let temp = TempDir::new().unwrap();
1063        let original = Config {
1064            mode: ConfigMode::NextGen,
1065            ..Default::default()
1066        };
1067
1068        original.save(temp.path()).unwrap();
1069
1070        // Verify the file contains "ng" not "nextgen"
1071        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1072        assert!(content.contains(r#"mode = "ng""#));
1073
1074        // Load it back
1075        let loaded = Config::load(temp.path()).unwrap();
1076        assert_eq!(loaded.mode, ConfigMode::NextGen);
1077    }
1078
1079    // ========== Config Merge Validation Tests ==========
1080
1081    #[test]
1082    fn test_config_merge_variant_field() {
1083        let mut base = Config::default();
1084        let other = Config {
1085            templates: TemplateConfig {
1086                format: None,
1087                variant: Some("minimal".to_string()),
1088                custom: None,
1089            },
1090            ..Default::default()
1091        };
1092
1093        base.merge(&other);
1094        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1095    }
1096
1097    #[test]
1098    fn test_config_merge_custom_field() {
1099        let mut base = Config::default();
1100        let other = Config {
1101            templates: TemplateConfig {
1102                format: None,
1103                variant: None,
1104                custom: Some(PathBuf::from("my-template.md")),
1105            },
1106            ..Default::default()
1107        };
1108
1109        base.merge(&other);
1110        assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1111    }
1112
1113    #[test]
1114    fn test_config_merge_does_not_overwrite_with_none() {
1115        let mut base = Config {
1116            templates: TemplateConfig {
1117                format: Some("madr".to_string()),
1118                variant: Some("minimal".to_string()),
1119                custom: Some(PathBuf::from("template.md")),
1120            },
1121            ..Default::default()
1122        };
1123        let other = Config::default(); // all template fields are None
1124
1125        base.merge(&other);
1126
1127        // None values in other should NOT overwrite existing values
1128        assert_eq!(base.templates.format, Some("madr".to_string()));
1129        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1130        assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1131    }
1132}