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            return Ok(config);
64        }
65
66        // Try legacy .adr-dir file
67        let legacy_path = root.join(LEGACY_CONFIG_FILE);
68        if legacy_path.exists() {
69            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
70            return Ok(Self {
71                adr_dir: PathBuf::from(adr_dir),
72                mode: ConfigMode::Compatible,
73                templates: TemplateConfig::default(),
74            });
75        }
76
77        // Check if default directory exists
78        let default_dir = root.join(DEFAULT_ADR_DIR);
79        if default_dir.exists() {
80            return Ok(Self::default());
81        }
82
83        Err(Error::AdrDirNotFound)
84    }
85
86    /// Load configuration, or return default if not found.
87    pub fn load_or_default(root: &Path) -> Self {
88        Self::load(root).unwrap_or_default()
89    }
90
91    /// Save configuration to the given directory.
92    pub fn save(&self, root: &Path) -> Result<()> {
93        match self.mode {
94            ConfigMode::Compatible => {
95                // Write legacy .adr-dir file
96                let path = root.join(LEGACY_CONFIG_FILE);
97                std::fs::write(&path, self.adr_dir.display().to_string())?;
98            }
99            ConfigMode::NextGen => {
100                // Write adrs.toml
101                let path = root.join(CONFIG_FILE);
102                let content =
103                    toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
104                std::fs::write(&path, content)?;
105            }
106        }
107        Ok(())
108    }
109
110    /// Returns the full path to the ADR directory.
111    pub fn adr_path(&self, root: &Path) -> PathBuf {
112        root.join(&self.adr_dir)
113    }
114
115    /// Returns true if running in next-gen mode.
116    pub fn is_next_gen(&self) -> bool {
117        matches!(self.mode, ConfigMode::NextGen)
118    }
119
120    /// Merge another config into this one (other takes precedence for set values).
121    pub fn merge(&mut self, other: &Config) {
122        // adr_dir: use other if it differs from default
123        if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
124            self.adr_dir = other.adr_dir.clone();
125        }
126        // mode: other takes precedence
127        self.mode = other.mode;
128        // templates: merge
129        if other.templates.format.is_some() {
130            self.templates.format = other.templates.format.clone();
131        }
132        if other.templates.custom.is_some() {
133            self.templates.custom = other.templates.custom.clone();
134        }
135    }
136}
137
138/// Result of discovering configuration.
139#[derive(Debug, Clone)]
140pub struct DiscoveredConfig {
141    /// The resolved configuration.
142    pub config: Config,
143    /// The project root directory (where config was found).
144    pub root: PathBuf,
145    /// Where the config was loaded from.
146    pub source: ConfigSource,
147}
148
149/// Where the configuration was loaded from.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ConfigSource {
152    /// Loaded from project config file.
153    Project(PathBuf),
154    /// Loaded from global config file.
155    Global(PathBuf),
156    /// Loaded from environment variable.
157    Environment,
158    /// Using defaults (no config found).
159    Default,
160}
161
162/// Discover configuration by searching up the directory tree.
163///
164/// Search order:
165/// 1. Environment variable `ADRS_CONFIG` (explicit config path)
166/// 2. Search upward from `start_dir` for `.adr-dir` or `adrs.toml`
167/// 3. Global config at `~/.config/adrs/config.toml`
168/// 4. Default configuration
169///
170/// Environment variable `ADR_DIRECTORY` overrides the ADR directory.
171pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
172    // Check for explicit config path from environment
173    if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
174        let path = PathBuf::from(&config_path);
175        if path.exists() {
176            let content = std::fs::read_to_string(&path)?;
177            let mut config: Config = toml::from_str(&content)?;
178            apply_env_overrides(&mut config);
179            return Ok(DiscoveredConfig {
180                config,
181                root: path
182                    .parent()
183                    .map(|p| p.to_path_buf())
184                    .unwrap_or_else(|| start_dir.to_path_buf()),
185                source: ConfigSource::Environment,
186            });
187        }
188    }
189
190    // Search upward for project config
191    if let Some((root, config, source)) = search_upward(start_dir)? {
192        let mut config = config;
193        apply_env_overrides(&mut config);
194        return Ok(DiscoveredConfig {
195            config,
196            root,
197            source,
198        });
199    }
200
201    // Try global config
202    if let Some((config, path)) = load_global_config()? {
203        let mut config = config;
204        apply_env_overrides(&mut config);
205        return Ok(DiscoveredConfig {
206            config,
207            root: start_dir.to_path_buf(),
208            source: ConfigSource::Global(path),
209        });
210    }
211
212    // Use defaults
213    let mut config = Config::default();
214    apply_env_overrides(&mut config);
215    Ok(DiscoveredConfig {
216        config,
217        root: start_dir.to_path_buf(),
218        source: ConfigSource::Default,
219    })
220}
221
222/// Search upward from the given directory for a config file.
223fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
224    let mut current = start_dir.to_path_buf();
225
226    loop {
227        // Check for adrs.toml first
228        let config_path = current.join(CONFIG_FILE);
229        if config_path.exists() {
230            let content = std::fs::read_to_string(&config_path)?;
231            let config: Config = toml::from_str(&content)?;
232            return Ok(Some((current, config, ConfigSource::Project(config_path))));
233        }
234
235        // Check for .adr-dir
236        let legacy_path = current.join(LEGACY_CONFIG_FILE);
237        if legacy_path.exists() {
238            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
239            let config = Config {
240                adr_dir: PathBuf::from(adr_dir),
241                mode: ConfigMode::Compatible,
242                templates: TemplateConfig::default(),
243            };
244            return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
245        }
246
247        // Check for default ADR directory (indicates project root)
248        let default_dir = current.join(DEFAULT_ADR_DIR);
249        if default_dir.exists() {
250            return Ok(Some((current, Config::default(), ConfigSource::Default)));
251        }
252
253        // Stop at git repository root
254        if current.join(".git").exists() {
255            break;
256        }
257
258        // Move to parent directory
259        match current.parent() {
260            Some(parent) => current = parent.to_path_buf(),
261            None => break,
262        }
263    }
264
265    Ok(None)
266}
267
268/// Load the global configuration file.
269fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
270    let config_dir = dirs_config_dir()?;
271    let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
272
273    if global_path.exists() {
274        let content = std::fs::read_to_string(&global_path)?;
275        let config: Config = toml::from_str(&content)?;
276        return Ok(Some((config, global_path)));
277    }
278
279    Ok(None)
280}
281
282/// Get the user's config directory.
283fn dirs_config_dir() -> Result<PathBuf> {
284    // Try XDG_CONFIG_HOME first, then fall back to ~/.config
285    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
286        return Ok(PathBuf::from(xdg));
287    }
288
289    if let Ok(home) = std::env::var("HOME") {
290        return Ok(PathBuf::from(home).join(".config"));
291    }
292
293    // Windows fallback
294    if let Ok(appdata) = std::env::var("APPDATA") {
295        return Ok(PathBuf::from(appdata));
296    }
297
298    Err(Error::ConfigError(
299        "Could not determine config directory".into(),
300    ))
301}
302
303/// Apply environment variable overrides to a config.
304fn apply_env_overrides(config: &mut Config) {
305    if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
306        config.adr_dir = PathBuf::from(adr_dir);
307    }
308}
309
310/// The mode of operation for the ADR tool.
311#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum ConfigMode {
314    /// Compatible with adr-tools (markdown-only, no frontmatter).
315    #[default]
316    Compatible,
317
318    /// Next-gen mode with YAML frontmatter and enhanced features.
319    #[serde(rename = "ng")]
320    NextGen,
321}
322
323/// Template configuration.
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325#[serde(default)]
326pub struct TemplateConfig {
327    /// The default template format to use.
328    pub format: Option<String>,
329
330    /// Path to a custom template file.
331    pub custom: Option<PathBuf>,
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use tempfile::TempDir;
338    use test_case::test_case;
339
340    // ========== Default and Constants Tests ==========
341
342    #[test]
343    fn test_default_config() {
344        let config = Config::default();
345        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
346        assert_eq!(config.mode, ConfigMode::Compatible);
347        assert!(config.templates.format.is_none());
348        assert!(config.templates.custom.is_none());
349    }
350
351    #[test]
352    fn test_constants() {
353        assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
354        assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
355        assert_eq!(CONFIG_FILE, "adrs.toml");
356    }
357
358    #[test]
359    fn test_config_mode_default() {
360        assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
361    }
362
363    // ========== Load Configuration Tests ==========
364
365    #[test]
366    fn test_load_legacy_config() {
367        let temp = TempDir::new().unwrap();
368        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
369
370        let config = Config::load(temp.path()).unwrap();
371        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
372        assert_eq!(config.mode, ConfigMode::Compatible);
373    }
374
375    #[test]
376    fn test_load_legacy_config_with_whitespace() {
377        let temp = TempDir::new().unwrap();
378        std::fs::write(temp.path().join(".adr-dir"), "  decisions  \n").unwrap();
379
380        let config = Config::load(temp.path()).unwrap();
381        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
382    }
383
384    #[test]
385    fn test_load_legacy_config_nested_path() {
386        let temp = TempDir::new().unwrap();
387        std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
388
389        let config = Config::load(temp.path()).unwrap();
390        assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
391    }
392
393    #[test]
394    fn test_load_new_config() {
395        let temp = TempDir::new().unwrap();
396        std::fs::write(
397            temp.path().join("adrs.toml"),
398            r#"
399adr_dir = "docs/decisions"
400mode = "ng"
401"#,
402        )
403        .unwrap();
404
405        let config = Config::load(temp.path()).unwrap();
406        assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
407        assert_eq!(config.mode, ConfigMode::NextGen);
408    }
409
410    #[test]
411    fn test_load_new_config_compatible_mode() {
412        let temp = TempDir::new().unwrap();
413        std::fs::write(
414            temp.path().join("adrs.toml"),
415            r#"
416adr_dir = "doc/adr"
417mode = "compatible"
418"#,
419        )
420        .unwrap();
421
422        let config = Config::load(temp.path()).unwrap();
423        assert_eq!(config.mode, ConfigMode::Compatible);
424    }
425
426    #[test]
427    fn test_load_new_config_with_templates() {
428        let temp = TempDir::new().unwrap();
429        std::fs::write(
430            temp.path().join("adrs.toml"),
431            r#"
432adr_dir = "decisions"
433mode = "ng"
434
435[templates]
436format = "markdown"
437custom = "templates/adr.md"
438"#,
439        )
440        .unwrap();
441
442        let config = Config::load(temp.path()).unwrap();
443        assert_eq!(config.templates.format, Some("markdown".to_string()));
444        assert_eq!(
445            config.templates.custom,
446            Some(PathBuf::from("templates/adr.md"))
447        );
448    }
449
450    #[test]
451    fn test_load_new_config_minimal() {
452        let temp = TempDir::new().unwrap();
453        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
454
455        let config = Config::load(temp.path()).unwrap();
456        assert_eq!(config.adr_dir, PathBuf::from("adrs"));
457        // Should use defaults for missing fields
458        assert_eq!(config.mode, ConfigMode::Compatible);
459    }
460
461    #[test]
462    fn test_load_prefers_new_config_over_legacy() {
463        let temp = TempDir::new().unwrap();
464        // Create both config files
465        std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
466        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
467
468        let config = Config::load(temp.path()).unwrap();
469        // Should prefer adrs.toml
470        assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
471    }
472
473    #[test]
474    fn test_load_default_dir_exists() {
475        let temp = TempDir::new().unwrap();
476        // Create the default directory
477        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
478
479        let config = Config::load(temp.path()).unwrap();
480        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
481    }
482
483    #[test]
484    fn test_load_no_config_no_default_dir() {
485        let temp = TempDir::new().unwrap();
486        // Empty directory - no config, no default dir
487
488        let result = Config::load(temp.path());
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn test_load_or_default_returns_default_on_error() {
494        let temp = TempDir::new().unwrap();
495        // Empty directory - would error with load()
496
497        let config = Config::load_or_default(temp.path());
498        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
499        assert_eq!(config.mode, ConfigMode::Compatible);
500    }
501
502    #[test]
503    fn test_load_or_default_returns_config_when_exists() {
504        let temp = TempDir::new().unwrap();
505        std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
506
507        let config = Config::load_or_default(temp.path());
508        assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
509    }
510
511    // ========== Save Configuration Tests ==========
512
513    #[test]
514    fn test_save_legacy_config() {
515        let temp = TempDir::new().unwrap();
516        let config = Config {
517            adr_dir: PathBuf::from("my/adrs"),
518            mode: ConfigMode::Compatible,
519            templates: TemplateConfig::default(),
520        };
521
522        config.save(temp.path()).unwrap();
523
524        let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
525        assert_eq!(content, "my/adrs");
526        // Should not create adrs.toml
527        assert!(!temp.path().join("adrs.toml").exists());
528    }
529
530    #[test]
531    fn test_save_new_config() {
532        let temp = TempDir::new().unwrap();
533        let config = Config {
534            adr_dir: PathBuf::from("docs/decisions"),
535            mode: ConfigMode::NextGen,
536            templates: TemplateConfig::default(),
537        };
538
539        config.save(temp.path()).unwrap();
540
541        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
542        assert!(content.contains("docs/decisions"));
543        assert!(content.contains("ng"));
544        // Should not create .adr-dir
545        assert!(!temp.path().join(".adr-dir").exists());
546    }
547
548    #[test]
549    fn test_save_new_config_with_templates() {
550        let temp = TempDir::new().unwrap();
551        let config = Config {
552            adr_dir: PathBuf::from("decisions"),
553            mode: ConfigMode::NextGen,
554            templates: TemplateConfig {
555                format: Some("custom".to_string()),
556                custom: Some(PathBuf::from("my-template.md")),
557            },
558        };
559
560        config.save(temp.path()).unwrap();
561
562        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
563        assert!(content.contains("custom"));
564        assert!(content.contains("my-template.md"));
565    }
566
567    #[test]
568    fn test_save_and_load_roundtrip_compatible() {
569        let temp = TempDir::new().unwrap();
570        let original = Config {
571            adr_dir: PathBuf::from("architecture/decisions"),
572            mode: ConfigMode::Compatible,
573            templates: TemplateConfig::default(),
574        };
575
576        original.save(temp.path()).unwrap();
577        let loaded = Config::load(temp.path()).unwrap();
578
579        assert_eq!(loaded.adr_dir, original.adr_dir);
580        assert_eq!(loaded.mode, ConfigMode::Compatible);
581    }
582
583    #[test]
584    fn test_save_and_load_roundtrip_nextgen() {
585        let temp = TempDir::new().unwrap();
586        let original = Config {
587            adr_dir: PathBuf::from("docs/adr"),
588            mode: ConfigMode::NextGen,
589            templates: TemplateConfig {
590                format: Some("markdown".to_string()),
591                custom: None,
592            },
593        };
594
595        original.save(temp.path()).unwrap();
596        let loaded = Config::load(temp.path()).unwrap();
597
598        assert_eq!(loaded.adr_dir, original.adr_dir);
599        assert_eq!(loaded.mode, ConfigMode::NextGen);
600        assert_eq!(loaded.templates.format, Some("markdown".to_string()));
601    }
602
603    // ========== Helper Method Tests ==========
604
605    #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
606    #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
607    #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
608    fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
609        let config = Config {
610            adr_dir: PathBuf::from(adr_dir),
611            ..Default::default()
612        };
613        config.adr_path(Path::new(root))
614    }
615
616    #[test]
617    fn test_is_next_gen() {
618        let compatible = Config {
619            mode: ConfigMode::Compatible,
620            ..Default::default()
621        };
622        assert!(!compatible.is_next_gen());
623
624        let nextgen = Config {
625            mode: ConfigMode::NextGen,
626            ..Default::default()
627        };
628        assert!(nextgen.is_next_gen());
629    }
630
631    // ========== ConfigMode Tests ==========
632
633    #[test]
634    fn test_config_mode_equality() {
635        assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
636        assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
637        assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
638    }
639
640    #[test]
641    fn test_config_mode_serialization_in_config() {
642        // TOML requires enums to be serialized within a struct
643        let config = Config {
644            mode: ConfigMode::Compatible,
645            ..Default::default()
646        };
647        let toml = toml::to_string(&config).unwrap();
648        assert!(toml.contains("mode = \"compatible\""));
649
650        let config = Config {
651            mode: ConfigMode::NextGen,
652            ..Default::default()
653        };
654        let toml = toml::to_string(&config).unwrap();
655        assert!(toml.contains("mode = \"ng\""));
656    }
657
658    #[test]
659    fn test_config_mode_deserialization_in_config() {
660        let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
661        assert_eq!(config.mode, ConfigMode::Compatible);
662
663        let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
664        assert_eq!(config.mode, ConfigMode::NextGen);
665    }
666
667    // ========== TemplateConfig Tests ==========
668
669    #[test]
670    fn test_template_config_default() {
671        let config = TemplateConfig::default();
672        assert!(config.format.is_none());
673        assert!(config.custom.is_none());
674    }
675
676    #[test]
677    fn test_template_config_serialization() {
678        let config = TemplateConfig {
679            format: Some("nygard".to_string()),
680            custom: Some(PathBuf::from("templates/custom.md")),
681        };
682
683        let toml = toml::to_string(&config).unwrap();
684        assert!(toml.contains("nygard"));
685        assert!(toml.contains("templates/custom.md"));
686    }
687
688    // ========== Error Cases ==========
689
690    #[test]
691    fn test_load_invalid_toml() {
692        let temp = TempDir::new().unwrap();
693        std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
694
695        let result = Config::load(temp.path());
696        assert!(result.is_err());
697    }
698
699    #[test]
700    fn test_load_empty_toml() {
701        let temp = TempDir::new().unwrap();
702        std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
703
704        // Empty TOML should use defaults
705        let config = Config::load(temp.path()).unwrap();
706        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
707    }
708
709    #[test]
710    fn test_load_empty_adr_dir_file() {
711        let temp = TempDir::new().unwrap();
712        std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
713
714        let config = Config::load(temp.path()).unwrap();
715        // Empty string becomes empty path
716        assert_eq!(config.adr_dir, PathBuf::from(""));
717    }
718
719    // ========== Config Discovery Tests ==========
720
721    #[test]
722    fn test_discover_finds_config_in_current_dir() {
723        let temp = TempDir::new().unwrap();
724        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
725
726        let discovered = discover(temp.path()).unwrap();
727        assert_eq!(discovered.root, temp.path());
728        assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
729        assert!(matches!(discovered.source, ConfigSource::Project(_)));
730    }
731
732    #[test]
733    fn test_discover_finds_config_in_parent_dir() {
734        let temp = TempDir::new().unwrap();
735        let subdir = temp.path().join("src").join("lib");
736        std::fs::create_dir_all(&subdir).unwrap();
737        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
738
739        let discovered = discover(&subdir).unwrap();
740        assert_eq!(discovered.root, temp.path());
741        assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
742    }
743
744    #[test]
745    fn test_discover_stops_at_git_root() {
746        let temp = TempDir::new().unwrap();
747
748        // Create a git repo structure
749        std::fs::create_dir(temp.path().join(".git")).unwrap();
750        let subdir = temp.path().join("src");
751        std::fs::create_dir(&subdir).unwrap();
752
753        // Put config above git root (should not be found)
754        // This test verifies we stop at .git
755
756        let result = discover(&subdir);
757        // Should return defaults since no config found within git repo
758        assert!(result.is_ok());
759        let discovered = result.unwrap();
760        assert!(matches!(discovered.source, ConfigSource::Default));
761    }
762
763    #[test]
764    fn test_discover_prefers_adrs_toml_over_adr_dir() {
765        let temp = TempDir::new().unwrap();
766        std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
767        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
768
769        let discovered = discover(temp.path()).unwrap();
770        assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
771    }
772
773    #[test]
774    fn test_discover_finds_default_adr_dir() {
775        let temp = TempDir::new().unwrap();
776        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
777
778        let discovered = discover(temp.path()).unwrap();
779        assert_eq!(discovered.root, temp.path());
780        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
781    }
782
783    #[test]
784    fn test_discover_returns_defaults_when_nothing_found() {
785        let temp = TempDir::new().unwrap();
786        // Create .git to stop search
787        std::fs::create_dir(temp.path().join(".git")).unwrap();
788
789        let discovered = discover(temp.path()).unwrap();
790        assert!(matches!(discovered.source, ConfigSource::Default));
791        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
792    }
793
794    #[test]
795    fn test_apply_env_overrides() {
796        // Test apply_env_overrides function directly without modifying the environment.
797        // The function reads env vars, so we test that it doesn't panic and returns
798        // when no env vars are set.
799        let mut config = Config::default();
800        apply_env_overrides(&mut config);
801        // With no env vars set, the config should remain at default
802        assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
803    }
804
805    #[test]
806    fn test_config_source_variants() {
807        // Test that ConfigSource can be compared
808        let project = ConfigSource::Project(PathBuf::from("test"));
809        let global = ConfigSource::Global(PathBuf::from("test"));
810        let env = ConfigSource::Environment;
811        let default = ConfigSource::Default;
812
813        assert_ne!(project, global);
814        assert_ne!(env, default);
815        assert_eq!(default, ConfigSource::Default);
816    }
817
818    #[test]
819    fn test_config_merge() {
820        let mut base = Config::default();
821        let other = Config {
822            adr_dir: PathBuf::from("custom"),
823            mode: ConfigMode::NextGen,
824            templates: TemplateConfig {
825                format: Some("madr".to_string()),
826                custom: None,
827            },
828        };
829
830        base.merge(&other);
831        assert_eq!(base.adr_dir, PathBuf::from("custom"));
832        assert_eq!(base.mode, ConfigMode::NextGen);
833        assert_eq!(base.templates.format, Some("madr".to_string()));
834    }
835
836    #[test]
837    fn test_config_merge_preserves_default_adr_dir() {
838        let mut base = Config {
839            adr_dir: PathBuf::from("original"),
840            ..Default::default()
841        };
842        let other = Config::default(); // has default adr_dir
843
844        base.merge(&other);
845        // Should keep original since other has default
846        assert_eq!(base.adr_dir, PathBuf::from("original"));
847    }
848}