Skip to main content

adrs_core/
config.rs

1//! Configuration handling for ADR repositories.
2
3use crate::{Error, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// Default ADR directory name.
8pub const DEFAULT_ADR_DIR: &str = "doc/adr";
9
10/// Legacy configuration file name (adr-tools compatible).
11pub const LEGACY_CONFIG_FILE: &str = ".adr-dir";
12
13/// New configuration file name.
14pub const CONFIG_FILE: &str = "adrs.toml";
15
16/// Global configuration file name.
17pub const GLOBAL_CONFIG_FILE: &str = "config.toml";
18
19/// Environment variable for ADR directory override.
20pub const ENV_ADR_DIRECTORY: &str = "ADR_DIRECTORY";
21
22/// Environment variable for config file path override.
23pub const ENV_ADRS_CONFIG: &str = "ADRS_CONFIG";
24
25/// Configuration for an ADR repository.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28pub struct Config {
29    /// The directory where ADRs are stored.
30    pub adr_dir: PathBuf,
31
32    /// The mode of operation.
33    pub mode: ConfigMode,
34
35    /// Default status for newly created ADRs.
36    pub default_status: Option<String>,
37
38    /// Skip opening the editor when creating a new ADR.
39    pub no_edit: bool,
40
41    /// Template configuration.
42    #[serde(default)]
43    pub templates: TemplateConfig,
44
45    /// Generate command configuration.
46    #[serde(default)]
47    pub generate: GenerateConfig,
48
49    /// Export command configuration.
50    #[serde(default)]
51    pub export: ExportConfig,
52}
53
54impl Default for Config {
55    fn default() -> Self {
56        Self {
57            adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
58            mode: ConfigMode::Compatible,
59            default_status: None,
60            no_edit: false,
61            templates: TemplateConfig::default(),
62            generate: GenerateConfig::default(),
63            export: ExportConfig::default(),
64        }
65    }
66}
67
68impl Config {
69    /// Load configuration from the given directory.
70    ///
71    /// Searches for configuration in the following order:
72    /// 1. `adrs.toml` (new format)
73    /// 2. `.adr-dir` (legacy adr-tools format)
74    /// 3. Default configuration
75    pub fn load(root: &Path) -> Result<Self> {
76        // Try new config first
77        let config_path = root.join(CONFIG_FILE);
78        if config_path.exists() {
79            let content = std::fs::read_to_string(&config_path)?;
80            let config: Config = toml::from_str(&content)?;
81            if config.adr_dir.as_os_str().is_empty() {
82                return Err(Error::ConfigError(
83                    "adr_dir cannot be empty in adrs.toml".into(),
84                ));
85            }
86            return Ok(config);
87        }
88
89        // Try legacy .adr-dir file
90        let legacy_path = root.join(LEGACY_CONFIG_FILE);
91        if legacy_path.exists() {
92            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
93            if adr_dir.is_empty() {
94                return Err(Error::ConfigError(
95                    "ADR directory path is empty in .adr-dir file".into(),
96                ));
97            }
98            return Ok(Self {
99                adr_dir: PathBuf::from(adr_dir),
100                mode: ConfigMode::Compatible,
101                default_status: None,
102                no_edit: false,
103                templates: TemplateConfig::default(),
104                generate: GenerateConfig::default(),
105                export: ExportConfig::default(),
106            });
107        }
108
109        // Check if default directory exists
110        let default_dir = root.join(DEFAULT_ADR_DIR);
111        if default_dir.exists() {
112            return Ok(Self::default());
113        }
114
115        Err(Error::AdrDirNotFound)
116    }
117
118    /// Load configuration, or return default if not found.
119    pub fn load_or_default(root: &Path) -> Self {
120        Self::load(root).unwrap_or_default()
121    }
122
123    /// Save configuration to the given directory.
124    pub fn save(&self, root: &Path) -> Result<()> {
125        match self.mode {
126            ConfigMode::Compatible => {
127                // Write legacy .adr-dir file
128                let path = root.join(LEGACY_CONFIG_FILE);
129                std::fs::write(&path, self.adr_dir.display().to_string())?;
130            }
131            ConfigMode::NextGen => {
132                // Write adrs.toml
133                let path = root.join(CONFIG_FILE);
134                let content =
135                    toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
136                std::fs::write(&path, content)?;
137            }
138        }
139        Ok(())
140    }
141
142    /// Returns the full path to the ADR directory.
143    pub fn adr_path(&self, root: &Path) -> PathBuf {
144        root.join(&self.adr_dir)
145    }
146
147    /// Returns true if running in next-gen mode.
148    pub fn is_next_gen(&self) -> bool {
149        matches!(self.mode, ConfigMode::NextGen)
150    }
151
152    /// Merge another config into this one (other takes precedence for set values).
153    pub fn merge(&mut self, other: &Config) {
154        // adr_dir: use other if it differs from default
155        if other.adr_dir.as_os_str() != DEFAULT_ADR_DIR {
156            self.adr_dir = other.adr_dir.clone();
157        }
158        // mode: other takes precedence
159        self.mode = other.mode;
160        // templates: merge
161        if other.templates.format.is_some() {
162            self.templates.format = other.templates.format.clone();
163        }
164        if other.templates.variant.is_some() {
165            self.templates.variant = other.templates.variant.clone();
166        }
167        if other.templates.custom.is_some() {
168            self.templates.custom = other.templates.custom.clone();
169        }
170        if other.default_status.is_some() {
171            self.default_status = other.default_status.clone();
172        }
173        if other.no_edit {
174            self.no_edit = other.no_edit;
175        }
176        if other.generate.toc_prefix.is_some() {
177            self.generate.toc_prefix = other.generate.toc_prefix.clone();
178        }
179        if other.export.base_url.is_some() {
180            self.export.base_url = other.export.base_url.clone();
181        }
182    }
183}
184
185/// Result of discovering configuration.
186#[derive(Debug, Clone)]
187pub struct DiscoveredConfig {
188    /// The resolved configuration.
189    pub config: Config,
190    /// The project root directory (where config was found).
191    pub root: PathBuf,
192    /// Where the config was loaded from.
193    pub source: ConfigSource,
194}
195
196/// Where the configuration was loaded from.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ConfigSource {
199    /// Loaded from project config file.
200    Project(PathBuf),
201    /// Loaded from global config file.
202    Global(PathBuf),
203    /// Loaded from environment variable.
204    Environment,
205    /// Using defaults (no config found).
206    Default,
207}
208
209/// Discover configuration by searching up the directory tree.
210///
211/// Search order:
212/// 1. Environment variable `ADRS_CONFIG` (explicit config path)
213/// 2. Search upward from `start_dir` for `.adr-dir` or `adrs.toml`
214/// 3. Global config at `~/.config/adrs/config.toml`
215/// 4. Default configuration
216///
217/// Environment variable `ADR_DIRECTORY` overrides the ADR directory.
218pub fn discover(start_dir: &Path) -> Result<DiscoveredConfig> {
219    // Check for explicit config path from environment
220    if let Ok(config_path) = std::env::var(ENV_ADRS_CONFIG) {
221        let path = PathBuf::from(&config_path);
222        if path.exists() {
223            let content = std::fs::read_to_string(&path)?;
224            let mut config: Config = toml::from_str(&content)?;
225            apply_env_overrides(&mut config);
226            return Ok(DiscoveredConfig {
227                config,
228                root: path
229                    .parent()
230                    .map(|p| p.to_path_buf())
231                    .unwrap_or_else(|| start_dir.to_path_buf()),
232                source: ConfigSource::Environment,
233            });
234        }
235    }
236
237    // Search upward for project config
238    if let Some((root, config, source)) = search_upward(start_dir)? {
239        let mut config = config;
240        apply_env_overrides(&mut config);
241        return Ok(DiscoveredConfig {
242            config,
243            root,
244            source,
245        });
246    }
247
248    // Try global config
249    if let Some((config, path)) = load_global_config()? {
250        let mut config = config;
251        apply_env_overrides(&mut config);
252        return Ok(DiscoveredConfig {
253            config,
254            root: start_dir.to_path_buf(),
255            source: ConfigSource::Global(path),
256        });
257    }
258
259    // Use defaults
260    let mut config = Config::default();
261    apply_env_overrides(&mut config);
262    Ok(DiscoveredConfig {
263        config,
264        root: start_dir.to_path_buf(),
265        source: ConfigSource::Default,
266    })
267}
268
269/// Search upward from the given directory for a config file.
270fn search_upward(start_dir: &Path) -> Result<Option<(PathBuf, Config, ConfigSource)>> {
271    let mut current = start_dir.to_path_buf();
272
273    loop {
274        // Check for adrs.toml first
275        let config_path = current.join(CONFIG_FILE);
276        if config_path.exists() {
277            let content = std::fs::read_to_string(&config_path)?;
278            let config: Config = toml::from_str(&content)?;
279            return Ok(Some((current, config, ConfigSource::Project(config_path))));
280        }
281
282        // Check for .adr-dir
283        let legacy_path = current.join(LEGACY_CONFIG_FILE);
284        if legacy_path.exists() {
285            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
286            let config = Config {
287                adr_dir: PathBuf::from(adr_dir),
288                mode: ConfigMode::Compatible,
289                default_status: None,
290                no_edit: false,
291                templates: TemplateConfig::default(),
292                generate: GenerateConfig::default(),
293                export: ExportConfig::default(),
294            };
295            return Ok(Some((current, config, ConfigSource::Project(legacy_path))));
296        }
297
298        // Check for default ADR directory (indicates project root)
299        let default_dir = current.join(DEFAULT_ADR_DIR);
300        if default_dir.exists() {
301            return Ok(Some((current, Config::default(), ConfigSource::Default)));
302        }
303
304        // Stop at git repository root
305        if current.join(".git").exists() {
306            break;
307        }
308
309        // Move to parent directory
310        match current.parent() {
311            Some(parent) => current = parent.to_path_buf(),
312            None => break,
313        }
314    }
315
316    Ok(None)
317}
318
319/// Load the global configuration file.
320fn load_global_config() -> Result<Option<(Config, PathBuf)>> {
321    let config_dir = dirs_config_dir()?;
322    let global_path = config_dir.join("adrs").join(GLOBAL_CONFIG_FILE);
323
324    if global_path.exists() {
325        let content = std::fs::read_to_string(&global_path)?;
326        let config: Config = toml::from_str(&content)?;
327        return Ok(Some((config, global_path)));
328    }
329
330    Ok(None)
331}
332
333/// Get the user's config directory.
334fn dirs_config_dir() -> Result<PathBuf> {
335    // Try XDG_CONFIG_HOME first, then fall back to ~/.config
336    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
337        return Ok(PathBuf::from(xdg));
338    }
339
340    if let Ok(home) = std::env::var("HOME") {
341        return Ok(PathBuf::from(home).join(".config"));
342    }
343
344    // Windows fallback
345    if let Ok(appdata) = std::env::var("APPDATA") {
346        return Ok(PathBuf::from(appdata));
347    }
348
349    Err(Error::ConfigError(
350        "Could not determine config directory".into(),
351    ))
352}
353
354/// Apply environment variable overrides to a config.
355fn apply_env_overrides(config: &mut Config) {
356    if let Ok(adr_dir) = std::env::var(ENV_ADR_DIRECTORY) {
357        config.adr_dir = PathBuf::from(adr_dir);
358    }
359}
360
361/// The mode of operation for the ADR tool.
362#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum ConfigMode {
365    /// Compatible with adr-tools (markdown-only, no frontmatter).
366    #[default]
367    Compatible,
368
369    /// Next-gen mode with YAML frontmatter and enhanced features.
370    #[serde(rename = "ng", alias = "nextgen")]
371    NextGen,
372}
373
374/// Template configuration.
375#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376#[serde(default)]
377pub struct TemplateConfig {
378    /// The default template format to use.
379    pub format: Option<String>,
380
381    /// The default template variant to use.
382    pub variant: Option<String>,
383
384    /// Path to a custom template file.
385    pub custom: Option<PathBuf>,
386}
387
388/// Generate command configuration.
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390#[serde(default)]
391pub struct GenerateConfig {
392    /// Default prefix for TOC links (used by `adrs generate toc` if `--prefix` is not given).
393    pub toc_prefix: Option<String>,
394}
395
396/// Export command configuration.
397#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398#[serde(default)]
399pub struct ExportConfig {
400    /// Default base URL for source_uri in JSON export (used by `adrs export json` if `--base-url` is not given).
401    pub base_url: Option<String>,
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use tempfile::TempDir;
408    use test_case::test_case;
409
410    // ========== Default and Constants Tests ==========
411
412    #[test]
413    fn test_default_config() {
414        let config = Config::default();
415        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
416        assert_eq!(config.mode, ConfigMode::Compatible);
417        assert!(config.default_status.is_none());
418        assert!(config.templates.format.is_none());
419        assert!(config.templates.custom.is_none());
420    }
421
422    #[test]
423    fn test_constants() {
424        assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
425        assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
426        assert_eq!(CONFIG_FILE, "adrs.toml");
427    }
428
429    #[test]
430    fn test_config_mode_default() {
431        assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
432    }
433
434    // ========== Load Configuration Tests ==========
435
436    #[test]
437    fn test_load_legacy_config() {
438        let temp = TempDir::new().unwrap();
439        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
440
441        let config = Config::load(temp.path()).unwrap();
442        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
443        assert_eq!(config.mode, ConfigMode::Compatible);
444    }
445
446    #[test]
447    fn test_load_legacy_config_with_whitespace() {
448        let temp = TempDir::new().unwrap();
449        std::fs::write(temp.path().join(".adr-dir"), "  decisions  \n").unwrap();
450
451        let config = Config::load(temp.path()).unwrap();
452        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
453    }
454
455    #[test]
456    fn test_load_legacy_config_nested_path() {
457        let temp = TempDir::new().unwrap();
458        std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
459
460        let config = Config::load(temp.path()).unwrap();
461        assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
462    }
463
464    #[test]
465    fn test_load_new_config() {
466        let temp = TempDir::new().unwrap();
467        std::fs::write(
468            temp.path().join("adrs.toml"),
469            r#"
470adr_dir = "docs/decisions"
471mode = "ng"
472"#,
473        )
474        .unwrap();
475
476        let config = Config::load(temp.path()).unwrap();
477        assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
478        assert_eq!(config.mode, ConfigMode::NextGen);
479    }
480
481    #[test]
482    fn test_load_new_config_compatible_mode() {
483        let temp = TempDir::new().unwrap();
484        std::fs::write(
485            temp.path().join("adrs.toml"),
486            r#"
487adr_dir = "doc/adr"
488mode = "compatible"
489"#,
490        )
491        .unwrap();
492
493        let config = Config::load(temp.path()).unwrap();
494        assert_eq!(config.mode, ConfigMode::Compatible);
495    }
496
497    #[test]
498    fn test_load_new_config_with_default_status() {
499        let temp = TempDir::new().unwrap();
500        std::fs::write(
501            temp.path().join("adrs.toml"),
502            r#"
503adr_dir = "doc/adr"
504default_status = "accepted"
505"#,
506        )
507        .unwrap();
508
509        let config = Config::load(temp.path()).unwrap();
510        assert_eq!(config.default_status, Some("accepted".to_string()));
511    }
512
513    #[test]
514    fn test_load_new_config_with_templates() {
515        let temp = TempDir::new().unwrap();
516        std::fs::write(
517            temp.path().join("adrs.toml"),
518            r#"
519adr_dir = "decisions"
520mode = "ng"
521
522[templates]
523format = "markdown"
524custom = "templates/adr.md"
525"#,
526        )
527        .unwrap();
528
529        let config = Config::load(temp.path()).unwrap();
530        assert_eq!(config.templates.format, Some("markdown".to_string()));
531        assert_eq!(
532            config.templates.custom,
533            Some(PathBuf::from("templates/adr.md"))
534        );
535    }
536
537    #[test]
538    fn test_load_new_config_with_template_variant() {
539        let temp = TempDir::new().unwrap();
540        std::fs::write(
541            temp.path().join("adrs.toml"),
542            r#"
543adr_dir = "decisions"
544mode = "ng"
545
546[templates]
547format = "madr"
548variant = "minimal"
549"#,
550        )
551        .unwrap();
552
553        let config = Config::load(temp.path()).unwrap();
554        assert_eq!(config.templates.format, Some("madr".to_string()));
555        assert_eq!(config.templates.variant, Some("minimal".to_string()));
556    }
557
558    #[test]
559    fn test_load_new_config_with_nextgen_alias() {
560        let temp = TempDir::new().unwrap();
561        std::fs::write(
562            temp.path().join("adrs.toml"),
563            r#"
564adr_dir = "decisions"
565mode = "nextgen"
566"#,
567        )
568        .unwrap();
569
570        let config = Config::load(temp.path()).unwrap();
571        assert_eq!(config.mode, ConfigMode::NextGen);
572    }
573
574    #[test]
575    fn test_load_new_config_minimal() {
576        let temp = TempDir::new().unwrap();
577        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
578
579        let config = Config::load(temp.path()).unwrap();
580        assert_eq!(config.adr_dir, PathBuf::from("adrs"));
581        // Should use defaults for missing fields
582        assert_eq!(config.mode, ConfigMode::Compatible);
583    }
584
585    #[test]
586    fn test_load_prefers_new_config_over_legacy() {
587        let temp = TempDir::new().unwrap();
588        // Create both config files
589        std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
590        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
591
592        let config = Config::load(temp.path()).unwrap();
593        // Should prefer adrs.toml
594        assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
595    }
596
597    #[test]
598    fn test_load_default_dir_exists() {
599        let temp = TempDir::new().unwrap();
600        // Create the default directory
601        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
602
603        let config = Config::load(temp.path()).unwrap();
604        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
605    }
606
607    #[test]
608    fn test_load_no_config_no_default_dir() {
609        let temp = TempDir::new().unwrap();
610        // Empty directory - no config, no default dir
611
612        let result = Config::load(temp.path());
613        assert!(result.is_err());
614    }
615
616    #[test]
617    fn test_load_or_default_returns_default_on_error() {
618        let temp = TempDir::new().unwrap();
619        // Empty directory - would error with load()
620
621        let config = Config::load_or_default(temp.path());
622        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
623        assert_eq!(config.mode, ConfigMode::Compatible);
624    }
625
626    #[test]
627    fn test_load_or_default_returns_config_when_exists() {
628        let temp = TempDir::new().unwrap();
629        std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
630
631        let config = Config::load_or_default(temp.path());
632        assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
633    }
634
635    // ========== Save Configuration Tests ==========
636
637    #[test]
638    fn test_save_legacy_config() {
639        let temp = TempDir::new().unwrap();
640        let config = Config {
641            adr_dir: PathBuf::from("my/adrs"),
642            mode: ConfigMode::Compatible,
643            default_status: None,
644            no_edit: false,
645            templates: TemplateConfig::default(),
646            generate: GenerateConfig::default(),
647            export: ExportConfig::default(),
648        };
649
650        config.save(temp.path()).unwrap();
651
652        let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
653        assert_eq!(content, "my/adrs");
654        // Should not create adrs.toml
655        assert!(!temp.path().join("adrs.toml").exists());
656    }
657
658    #[test]
659    fn test_save_new_config() {
660        let temp = TempDir::new().unwrap();
661        let config = Config {
662            adr_dir: PathBuf::from("docs/decisions"),
663            mode: ConfigMode::NextGen,
664            default_status: None,
665            no_edit: false,
666            templates: TemplateConfig::default(),
667            generate: GenerateConfig::default(),
668            export: ExportConfig::default(),
669        };
670
671        config.save(temp.path()).unwrap();
672
673        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
674        assert!(content.contains("docs/decisions"));
675        assert!(content.contains("ng"));
676        // Should not create .adr-dir
677        assert!(!temp.path().join(".adr-dir").exists());
678    }
679
680    #[test]
681    fn test_save_new_config_with_templates() {
682        let temp = TempDir::new().unwrap();
683        let config = Config {
684            adr_dir: PathBuf::from("decisions"),
685            mode: ConfigMode::NextGen,
686            default_status: None,
687            no_edit: false,
688            templates: TemplateConfig {
689                format: Some("custom".to_string()),
690                variant: None,
691                custom: Some(PathBuf::from("my-template.md")),
692            },
693            generate: GenerateConfig::default(),
694            export: ExportConfig::default(),
695        };
696
697        config.save(temp.path()).unwrap();
698
699        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
700        assert!(content.contains("custom"));
701        assert!(content.contains("my-template.md"));
702    }
703
704    #[test]
705    fn test_save_and_load_roundtrip_compatible() {
706        let temp = TempDir::new().unwrap();
707        let original = Config {
708            adr_dir: PathBuf::from("architecture/decisions"),
709            mode: ConfigMode::Compatible,
710            default_status: None,
711            no_edit: false,
712            templates: TemplateConfig::default(),
713            generate: GenerateConfig::default(),
714            export: ExportConfig::default(),
715        };
716
717        original.save(temp.path()).unwrap();
718        let loaded = Config::load(temp.path()).unwrap();
719
720        assert_eq!(loaded.adr_dir, original.adr_dir);
721        assert_eq!(loaded.mode, ConfigMode::Compatible);
722    }
723
724    #[test]
725    fn test_save_and_load_roundtrip_nextgen() {
726        let temp = TempDir::new().unwrap();
727        let original = Config {
728            adr_dir: PathBuf::from("docs/adr"),
729            mode: ConfigMode::NextGen,
730            default_status: None,
731            no_edit: false,
732            templates: TemplateConfig {
733                format: Some("markdown".to_string()),
734                variant: None,
735                custom: None,
736            },
737            generate: GenerateConfig::default(),
738            export: ExportConfig::default(),
739        };
740
741        original.save(temp.path()).unwrap();
742        let loaded = Config::load(temp.path()).unwrap();
743
744        assert_eq!(loaded.adr_dir, original.adr_dir);
745        assert_eq!(loaded.mode, ConfigMode::NextGen);
746        assert_eq!(loaded.templates.format, Some("markdown".to_string()));
747    }
748
749    // ========== Helper Method Tests ==========
750
751    #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
752    #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
753    #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
754    fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
755        let config = Config {
756            adr_dir: PathBuf::from(adr_dir),
757            ..Default::default()
758        };
759        config.adr_path(Path::new(root))
760    }
761
762    #[test]
763    fn test_is_next_gen() {
764        let compatible = Config {
765            mode: ConfigMode::Compatible,
766            ..Default::default()
767        };
768        assert!(!compatible.is_next_gen());
769
770        let nextgen = Config {
771            mode: ConfigMode::NextGen,
772            ..Default::default()
773        };
774        assert!(nextgen.is_next_gen());
775    }
776
777    // ========== ConfigMode Tests ==========
778
779    #[test]
780    fn test_config_mode_equality() {
781        assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
782        assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
783        assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
784    }
785
786    #[test]
787    fn test_config_mode_serialization_in_config() {
788        // TOML requires enums to be serialized within a struct
789        let config = Config {
790            mode: ConfigMode::Compatible,
791            ..Default::default()
792        };
793        let toml = toml::to_string(&config).unwrap();
794        assert!(toml.contains("mode = \"compatible\""));
795
796        let config = Config {
797            mode: ConfigMode::NextGen,
798            ..Default::default()
799        };
800        let toml = toml::to_string(&config).unwrap();
801        assert!(toml.contains("mode = \"ng\""));
802    }
803
804    #[test]
805    fn test_config_mode_deserialization_in_config() {
806        let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
807        assert_eq!(config.mode, ConfigMode::Compatible);
808
809        let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
810        assert_eq!(config.mode, ConfigMode::NextGen);
811    }
812
813    #[test]
814    fn test_config_mode_deserialization_nextgen_alias() {
815        let config: Config = toml::from_str(r#"mode = "nextgen""#).unwrap();
816        assert_eq!(config.mode, ConfigMode::NextGen);
817    }
818
819    // ========== TemplateConfig Tests ==========
820
821    #[test]
822    fn test_template_config_default() {
823        let config = TemplateConfig::default();
824        assert!(config.format.is_none());
825        assert!(config.variant.is_none());
826        assert!(config.custom.is_none());
827    }
828
829    #[test]
830    fn test_template_config_serialization() {
831        let config = TemplateConfig {
832            format: Some("nygard".to_string()),
833            variant: None,
834            custom: Some(PathBuf::from("templates/custom.md")),
835        };
836
837        let toml = toml::to_string(&config).unwrap();
838        assert!(toml.contains("nygard"));
839        assert!(toml.contains("templates/custom.md"));
840    }
841
842    // ========== Error Cases ==========
843
844    #[test]
845    fn test_load_invalid_toml() {
846        let temp = TempDir::new().unwrap();
847        std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
848
849        let result = Config::load(temp.path());
850        assert!(result.is_err());
851    }
852
853    #[test]
854    fn test_load_empty_toml() {
855        let temp = TempDir::new().unwrap();
856        std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
857
858        // Empty TOML should use defaults
859        let config = Config::load(temp.path()).unwrap();
860        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
861    }
862
863    #[test]
864    fn test_load_empty_adr_dir_file() {
865        let temp = TempDir::new().unwrap();
866        std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
867
868        let result = Config::load(temp.path());
869        assert!(result.is_err(), "Empty .adr-dir should produce an error");
870    }
871
872    // ========== Config Discovery Tests ==========
873
874    #[test]
875    fn test_discover_finds_config_in_current_dir() {
876        let temp = TempDir::new().unwrap();
877        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
878
879        let discovered = discover(temp.path()).unwrap();
880        assert_eq!(discovered.root, temp.path());
881        assert_eq!(discovered.config.adr_dir, PathBuf::from("decisions"));
882        assert!(matches!(discovered.source, ConfigSource::Project(_)));
883    }
884
885    #[test]
886    fn test_discover_finds_config_in_parent_dir() {
887        let temp = TempDir::new().unwrap();
888        let subdir = temp.path().join("src").join("lib");
889        std::fs::create_dir_all(&subdir).unwrap();
890        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "docs/adr""#).unwrap();
891
892        let discovered = discover(&subdir).unwrap();
893        assert_eq!(discovered.root, temp.path());
894        assert_eq!(discovered.config.adr_dir, PathBuf::from("docs/adr"));
895    }
896
897    #[test]
898    fn test_discover_stops_at_git_root() {
899        let temp = TempDir::new().unwrap();
900
901        // Create a git repo structure
902        std::fs::create_dir(temp.path().join(".git")).unwrap();
903        let subdir = temp.path().join("src");
904        std::fs::create_dir(&subdir).unwrap();
905
906        // Put config above git root (should not be found)
907        // This test verifies we stop at .git
908
909        let result = discover(&subdir);
910        // Should return defaults since no config found within git repo
911        assert!(result.is_ok());
912        let discovered = result.unwrap();
913        assert!(matches!(discovered.source, ConfigSource::Default));
914    }
915
916    #[test]
917    fn test_discover_prefers_adrs_toml_over_adr_dir() {
918        let temp = TempDir::new().unwrap();
919        std::fs::write(temp.path().join(".adr-dir"), "legacy").unwrap();
920        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "modern""#).unwrap();
921
922        let discovered = discover(temp.path()).unwrap();
923        assert_eq!(discovered.config.adr_dir, PathBuf::from("modern"));
924    }
925
926    #[test]
927    fn test_discover_finds_default_adr_dir() {
928        let temp = TempDir::new().unwrap();
929        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
930
931        let discovered = discover(temp.path()).unwrap();
932        assert_eq!(discovered.root, temp.path());
933        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
934    }
935
936    #[test]
937    fn test_discover_returns_defaults_when_nothing_found() {
938        let temp = TempDir::new().unwrap();
939        // Create .git to stop search
940        std::fs::create_dir(temp.path().join(".git")).unwrap();
941
942        let discovered = discover(temp.path()).unwrap();
943        assert!(matches!(discovered.source, ConfigSource::Default));
944        assert_eq!(discovered.config.adr_dir, PathBuf::from("doc/adr"));
945    }
946
947    // Serializes tests that mutate the process-global ADR_DIRECTORY env var.
948    // `std::env` is shared across cargo's parallel test threads, so without this
949    // lock these tests race and clobber each other's values (issue #241 follow-up).
950    // `unwrap_or_else(into_inner)` recovers the guard even if a prior test panicked
951    // while holding the lock (poisoned mutex).
952    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
953
954    #[test]
955    fn test_apply_env_overrides() {
956        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
957        // Test apply_env_overrides when ADR_DIRECTORY is not set.
958        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
959        // SAFETY: serialized by ENV_LOCK; env restored before returning.
960        unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
961
962        let mut config = Config::default();
963        apply_env_overrides(&mut config);
964
965        unsafe {
966            if let Some(v) = old {
967                std::env::set_var(ENV_ADR_DIRECTORY, v);
968            }
969        }
970
971        // With no env var set, the config should remain at default
972        assert_eq!(config.adr_dir, PathBuf::from(DEFAULT_ADR_DIR));
973    }
974
975    // ========== apply_env_overrides positive cases (issue #241) ==========
976    // Env vars are process-global; these tests serialize via ENV_LOCK and
977    // save/restore the old value so they neither race nor leak state.
978    // In Rust 2024 edition, set_var/remove_var require unsafe blocks.
979
980    #[test]
981    fn test_apply_env_overrides_sets_adr_dir() {
982        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
983        // Save old value
984        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
985
986        // SAFETY: single-threaded test; restoring env after test
987        unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "my/custom/adr/dir") };
988        let mut config = Config::default();
989        apply_env_overrides(&mut config);
990
991        // Restore before asserting (so a panic does not leave env dirty)
992        unsafe {
993            match old {
994                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
995                None => std::env::remove_var(ENV_ADR_DIRECTORY),
996            }
997        }
998
999        assert_eq!(config.adr_dir, PathBuf::from("my/custom/adr/dir"));
1000    }
1001
1002    #[test]
1003    fn test_apply_env_overrides_overrides_non_default_adr_dir() {
1004        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1005        // Verify env override wins even when config already has a custom path
1006        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1007
1008        // SAFETY: single-threaded test; restoring env after test
1009        unsafe { std::env::set_var(ENV_ADR_DIRECTORY, "env_override") };
1010        let mut config = Config {
1011            adr_dir: PathBuf::from("config_dir"),
1012            ..Default::default()
1013        };
1014        apply_env_overrides(&mut config);
1015
1016        unsafe {
1017            match old {
1018                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1019                None => std::env::remove_var(ENV_ADR_DIRECTORY),
1020            }
1021        }
1022
1023        assert_eq!(config.adr_dir, PathBuf::from("env_override"));
1024    }
1025
1026    #[test]
1027    fn test_apply_env_overrides_no_adr_dir_var_leaves_config_unchanged() {
1028        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1029        // Without ADR_DIRECTORY set, config is unchanged
1030        let old = std::env::var(ENV_ADR_DIRECTORY).ok();
1031
1032        // SAFETY: single-threaded test; restoring env after test
1033        unsafe { std::env::remove_var(ENV_ADR_DIRECTORY) };
1034
1035        let mut config = Config {
1036            adr_dir: PathBuf::from("original/path"),
1037            ..Default::default()
1038        };
1039        apply_env_overrides(&mut config);
1040
1041        unsafe {
1042            match old {
1043                Some(v) => std::env::set_var(ENV_ADR_DIRECTORY, v),
1044                None => std::env::remove_var(ENV_ADR_DIRECTORY),
1045            }
1046        }
1047
1048        assert_eq!(config.adr_dir, PathBuf::from("original/path"));
1049    }
1050
1051    #[test]
1052    fn test_config_source_variants() {
1053        // Test that ConfigSource can be compared
1054        let project = ConfigSource::Project(PathBuf::from("test"));
1055        let global = ConfigSource::Global(PathBuf::from("test"));
1056        let env = ConfigSource::Environment;
1057        let default = ConfigSource::Default;
1058
1059        assert_ne!(project, global);
1060        assert_ne!(env, default);
1061        assert_eq!(default, ConfigSource::Default);
1062    }
1063
1064    #[test]
1065    fn test_config_merge() {
1066        let mut base = Config::default();
1067        let other = Config {
1068            adr_dir: PathBuf::from("custom"),
1069            mode: ConfigMode::NextGen,
1070            default_status: None,
1071            no_edit: false,
1072            templates: TemplateConfig {
1073                format: Some("madr".to_string()),
1074                variant: None,
1075                custom: None,
1076            },
1077            generate: GenerateConfig::default(),
1078            export: ExportConfig::default(),
1079        };
1080
1081        base.merge(&other);
1082        assert_eq!(base.adr_dir, PathBuf::from("custom"));
1083        assert_eq!(base.mode, ConfigMode::NextGen);
1084        assert_eq!(base.templates.format, Some("madr".to_string()));
1085    }
1086
1087    #[test]
1088    fn test_config_merge_preserves_default_adr_dir() {
1089        let mut base = Config {
1090            adr_dir: PathBuf::from("original"),
1091            ..Default::default()
1092        };
1093        let other = Config::default(); // has default adr_dir
1094
1095        base.merge(&other);
1096        // Should keep original since other has default
1097        assert_eq!(base.adr_dir, PathBuf::from("original"));
1098    }
1099
1100    #[test]
1101    fn test_config_merge_default_status() {
1102        let mut base = Config::default();
1103        let other = Config {
1104            default_status: Some("accepted".to_string()),
1105            ..Default::default()
1106        };
1107
1108        base.merge(&other);
1109        assert_eq!(base.default_status, Some("accepted".to_string()));
1110    }
1111
1112    // ========== no_edit Tests ==========
1113
1114    #[test]
1115    fn test_no_edit_defaults_to_false() {
1116        let config = Config::default();
1117        assert!(!config.no_edit);
1118    }
1119
1120    #[test]
1121    fn test_no_edit_true_from_toml() {
1122        let temp = TempDir::new().unwrap();
1123        std::fs::write(
1124            temp.path().join("adrs.toml"),
1125            "adr_dir = \"doc/adr\"\nno_edit = true\n",
1126        )
1127        .unwrap();
1128        let config = Config::load(temp.path()).unwrap();
1129        assert!(config.no_edit);
1130    }
1131
1132    #[test]
1133    fn test_no_edit_false_from_toml() {
1134        let temp = TempDir::new().unwrap();
1135        std::fs::write(
1136            temp.path().join("adrs.toml"),
1137            "adr_dir = \"doc/adr\"\nno_edit = false\n",
1138        )
1139        .unwrap();
1140        let config = Config::load(temp.path()).unwrap();
1141        assert!(!config.no_edit);
1142    }
1143
1144    #[test]
1145    fn test_no_edit_absent_from_toml_defaults_false() {
1146        let temp = TempDir::new().unwrap();
1147        std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1148        let config = Config::load(temp.path()).unwrap();
1149        assert!(!config.no_edit);
1150    }
1151
1152    #[test]
1153    fn test_config_merge_no_edit() {
1154        let mut base = Config::default();
1155        let other = Config {
1156            no_edit: true,
1157            ..Default::default()
1158        };
1159        base.merge(&other);
1160        assert!(base.no_edit);
1161    }
1162
1163    #[test]
1164    fn test_config_merge_no_edit_false_does_not_overwrite_true() {
1165        // merge: other.no_edit=false should NOT overwrite base.no_edit=true
1166        let mut base = Config {
1167            no_edit: true,
1168            ..Default::default()
1169        };
1170        let other = Config::default(); // no_edit = false
1171        base.merge(&other);
1172        assert!(
1173            base.no_edit,
1174            "merge with no_edit=false should not overwrite true"
1175        );
1176    }
1177
1178    #[test]
1179    fn test_no_edit_save_load_roundtrip() {
1180        let temp = TempDir::new().unwrap();
1181        let original = Config {
1182            adr_dir: PathBuf::from("doc/adr"),
1183            mode: ConfigMode::NextGen,
1184            no_edit: true,
1185            ..Default::default()
1186        };
1187        original.save(temp.path()).unwrap();
1188        let loaded = Config::load(temp.path()).unwrap();
1189        assert!(loaded.no_edit);
1190    }
1191
1192    // ========== Config Validation Tests ==========
1193
1194    #[test]
1195    fn test_load_empty_adr_dir_in_toml() {
1196        let temp = TempDir::new().unwrap();
1197        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = """#).unwrap();
1198
1199        let result = Config::load(temp.path());
1200        assert!(
1201            result.is_err(),
1202            "Empty adr_dir in TOML should produce an error"
1203        );
1204    }
1205
1206    #[test]
1207    fn test_load_whitespace_only_adr_dir_file() {
1208        let temp = TempDir::new().unwrap();
1209        std::fs::write(temp.path().join(".adr-dir"), "   \n  ").unwrap();
1210
1211        let result = Config::load(temp.path());
1212        assert!(
1213            result.is_err(),
1214            "Whitespace-only .adr-dir should produce an error"
1215        );
1216    }
1217
1218    #[test]
1219    fn test_invalid_format_string_accepted_in_toml() {
1220        // Invalid format strings are stored as-is in config; they only error
1221        // when parsed at ADR creation time. This is by design — the config
1222        // layer stores strings, the command layer validates them.
1223        let temp = TempDir::new().unwrap();
1224        std::fs::write(
1225            temp.path().join("adrs.toml"),
1226            r#"
1227adr_dir = "doc/adr"
1228
1229[templates]
1230format = "nonexistent"
1231"#,
1232        )
1233        .unwrap();
1234
1235        let config = Config::load(temp.path()).unwrap();
1236        assert_eq!(config.templates.format, Some("nonexistent".to_string()));
1237    }
1238
1239    #[test]
1240    fn test_invalid_variant_string_accepted_in_toml() {
1241        let temp = TempDir::new().unwrap();
1242        std::fs::write(
1243            temp.path().join("adrs.toml"),
1244            r#"
1245adr_dir = "doc/adr"
1246
1247[templates]
1248variant = "bogus"
1249"#,
1250        )
1251        .unwrap();
1252
1253        let config = Config::load(temp.path()).unwrap();
1254        assert_eq!(config.templates.variant, Some("bogus".to_string()));
1255    }
1256
1257    #[test]
1258    fn test_invalid_mode_string_rejected() {
1259        let temp = TempDir::new().unwrap();
1260        std::fs::write(temp.path().join("adrs.toml"), r#"mode = "invalid_mode""#).unwrap();
1261
1262        let result = Config::load(temp.path());
1263        assert!(
1264            result.is_err(),
1265            "Invalid mode should produce a TOML parse error"
1266        );
1267    }
1268
1269    #[test]
1270    fn test_unknown_toml_fields_accepted() {
1271        // Serde's default behavior: unknown fields are silently ignored.
1272        let temp = TempDir::new().unwrap();
1273        std::fs::write(
1274            temp.path().join("adrs.toml"),
1275            r#"
1276adr_dir = "doc/adr"
1277unknown_field = "hello"
1278
1279[templates]
1280also_unknown = true
1281"#,
1282        )
1283        .unwrap();
1284
1285        let config = Config::load(temp.path()).unwrap();
1286        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
1287    }
1288
1289    #[test]
1290    fn test_custom_template_path_in_config() {
1291        let temp = TempDir::new().unwrap();
1292        std::fs::write(
1293            temp.path().join("adrs.toml"),
1294            r#"
1295adr_dir = "doc/adr"
1296mode = "ng"
1297
1298[templates]
1299custom = "templates/my-adr.md"
1300"#,
1301        )
1302        .unwrap();
1303
1304        let config = Config::load(temp.path()).unwrap();
1305        assert_eq!(
1306            config.templates.custom,
1307            Some(PathBuf::from("templates/my-adr.md"))
1308        );
1309    }
1310
1311    // ========== Save/Load Roundtrip Tests ==========
1312
1313    #[test]
1314    fn test_save_and_load_roundtrip_nextgen_with_templates() {
1315        let temp = TempDir::new().unwrap();
1316        let original = Config {
1317            adr_dir: PathBuf::from("docs/decisions"),
1318            mode: ConfigMode::NextGen,
1319            default_status: None,
1320            no_edit: false,
1321            templates: TemplateConfig {
1322                format: Some("madr".to_string()),
1323                variant: Some("minimal".to_string()),
1324                custom: Some(PathBuf::from("templates/custom.md")),
1325            },
1326            generate: GenerateConfig::default(),
1327            export: ExportConfig::default(),
1328        };
1329
1330        original.save(temp.path()).unwrap();
1331        let loaded = Config::load(temp.path()).unwrap();
1332
1333        assert_eq!(loaded.adr_dir, PathBuf::from("docs/decisions"));
1334        assert_eq!(loaded.mode, ConfigMode::NextGen);
1335        assert_eq!(loaded.templates.format, Some("madr".to_string()));
1336        assert_eq!(loaded.templates.variant, Some("minimal".to_string()));
1337        assert_eq!(
1338            loaded.templates.custom,
1339            Some(PathBuf::from("templates/custom.md"))
1340        );
1341    }
1342
1343    #[test]
1344    fn test_save_and_load_roundtrip_nextgen_mode_serializes_as_ng() {
1345        // NextGen serializes as "ng" but should load back as NextGen
1346        let temp = TempDir::new().unwrap();
1347        let original = Config {
1348            mode: ConfigMode::NextGen,
1349            ..Default::default()
1350        };
1351
1352        original.save(temp.path()).unwrap();
1353
1354        // Verify the file contains "ng" not "nextgen"
1355        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
1356        assert!(content.contains(r#"mode = "ng""#));
1357
1358        // Load it back
1359        let loaded = Config::load(temp.path()).unwrap();
1360        assert_eq!(loaded.mode, ConfigMode::NextGen);
1361    }
1362
1363    // ========== Config Merge Validation Tests ==========
1364
1365    #[test]
1366    fn test_config_merge_variant_field() {
1367        let mut base = Config::default();
1368        let other = Config {
1369            templates: TemplateConfig {
1370                format: None,
1371                variant: Some("minimal".to_string()),
1372                custom: None,
1373            },
1374            ..Default::default()
1375        };
1376
1377        base.merge(&other);
1378        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1379    }
1380
1381    #[test]
1382    fn test_config_merge_custom_field() {
1383        let mut base = Config::default();
1384        let other = Config {
1385            templates: TemplateConfig {
1386                format: None,
1387                variant: None,
1388                custom: Some(PathBuf::from("my-template.md")),
1389            },
1390            ..Default::default()
1391        };
1392
1393        base.merge(&other);
1394        assert_eq!(base.templates.custom, Some(PathBuf::from("my-template.md")));
1395    }
1396
1397    #[test]
1398    fn test_config_merge_does_not_overwrite_with_none() {
1399        let mut base = Config {
1400            templates: TemplateConfig {
1401                format: Some("madr".to_string()),
1402                variant: Some("minimal".to_string()),
1403                custom: Some(PathBuf::from("template.md")),
1404            },
1405            ..Default::default()
1406        };
1407        let other = Config::default(); // all template fields are None
1408
1409        base.merge(&other);
1410
1411        // None values in other should NOT overwrite existing values
1412        assert_eq!(base.templates.format, Some("madr".to_string()));
1413        assert_eq!(base.templates.variant, Some("minimal".to_string()));
1414        assert_eq!(base.templates.custom, Some(PathBuf::from("template.md")));
1415    }
1416
1417    // ========== GenerateConfig / toc_prefix Tests ==========
1418
1419    #[test]
1420    fn test_generate_config_default() {
1421        let config = GenerateConfig::default();
1422        assert!(config.toc_prefix.is_none());
1423    }
1424
1425    #[test]
1426    fn test_generate_toc_prefix_from_toml() {
1427        let temp = TempDir::new().unwrap();
1428        std::fs::write(
1429            temp.path().join("adrs.toml"),
1430            r#"
1431adr_dir = "doc/adr"
1432
1433[generate]
1434toc_prefix = "./"
1435"#,
1436        )
1437        .unwrap();
1438
1439        let config = Config::load(temp.path()).unwrap();
1440        assert_eq!(config.generate.toc_prefix, Some("./".to_string()));
1441    }
1442
1443    #[test]
1444    fn test_generate_toc_prefix_absent_defaults_none() {
1445        let temp = TempDir::new().unwrap();
1446        std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1447
1448        let config = Config::load(temp.path()).unwrap();
1449        assert!(config.generate.toc_prefix.is_none());
1450    }
1451
1452    #[test]
1453    fn test_config_merge_generate_toc_prefix() {
1454        let mut base = Config::default();
1455        let other = Config {
1456            generate: GenerateConfig {
1457                toc_prefix: Some("./".to_string()),
1458            },
1459            ..Default::default()
1460        };
1461
1462        base.merge(&other);
1463        assert_eq!(base.generate.toc_prefix, Some("./".to_string()));
1464    }
1465
1466    #[test]
1467    fn test_config_merge_generate_toc_prefix_none_does_not_overwrite() {
1468        let mut base = Config {
1469            generate: GenerateConfig {
1470                toc_prefix: Some("docs/".to_string()),
1471            },
1472            ..Default::default()
1473        };
1474        let other = Config::default(); // toc_prefix = None
1475
1476        base.merge(&other);
1477        assert_eq!(
1478            base.generate.toc_prefix,
1479            Some("docs/".to_string()),
1480            "merge with toc_prefix=None should not overwrite existing value"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_generate_toc_prefix_save_load_roundtrip() {
1486        let temp = TempDir::new().unwrap();
1487        let original = Config {
1488            mode: ConfigMode::NextGen,
1489            generate: GenerateConfig {
1490                toc_prefix: Some("wiki/adr/".to_string()),
1491            },
1492            ..Default::default()
1493        };
1494
1495        original.save(temp.path()).unwrap();
1496        let loaded = Config::load(temp.path()).unwrap();
1497
1498        assert_eq!(
1499            loaded.generate.toc_prefix,
1500            Some("wiki/adr/".to_string()),
1501            "toc_prefix should survive a save/load round-trip"
1502        );
1503    }
1504
1505    // ========== ExportConfig / base_url Tests ==========
1506
1507    #[test]
1508    fn test_export_config_default() {
1509        let config = ExportConfig::default();
1510        assert!(config.base_url.is_none());
1511    }
1512
1513    #[test]
1514    fn test_export_base_url_from_toml() {
1515        let temp = TempDir::new().unwrap();
1516        std::fs::write(
1517            temp.path().join("adrs.toml"),
1518            r#"
1519adr_dir = "doc/adr"
1520
1521[export]
1522base_url = "https://github.com/org/repo/blob/main/doc/adr"
1523"#,
1524        )
1525        .unwrap();
1526
1527        let config = Config::load(temp.path()).unwrap();
1528        assert_eq!(
1529            config.export.base_url,
1530            Some("https://github.com/org/repo/blob/main/doc/adr".to_string())
1531        );
1532    }
1533
1534    #[test]
1535    fn test_export_base_url_absent_defaults_none() {
1536        let temp = TempDir::new().unwrap();
1537        std::fs::write(temp.path().join("adrs.toml"), "adr_dir = \"doc/adr\"\n").unwrap();
1538
1539        let config = Config::load(temp.path()).unwrap();
1540        assert!(config.export.base_url.is_none());
1541    }
1542
1543    #[test]
1544    fn test_config_merge_export_base_url() {
1545        let mut base = Config::default();
1546        let other = Config {
1547            export: ExportConfig {
1548                base_url: Some("https://example.com/adr".to_string()),
1549            },
1550            ..Default::default()
1551        };
1552
1553        base.merge(&other);
1554        assert_eq!(
1555            base.export.base_url,
1556            Some("https://example.com/adr".to_string())
1557        );
1558    }
1559
1560    #[test]
1561    fn test_config_merge_export_base_url_none_does_not_overwrite() {
1562        let mut base = Config {
1563            export: ExportConfig {
1564                base_url: Some("https://existing.example.com/adr".to_string()),
1565            },
1566            ..Default::default()
1567        };
1568        let other = Config::default(); // export.base_url = None
1569
1570        base.merge(&other);
1571        assert_eq!(
1572            base.export.base_url,
1573            Some("https://existing.example.com/adr".to_string()),
1574            "merge with export.base_url=None should not overwrite existing value"
1575        );
1576    }
1577
1578    #[test]
1579    fn test_export_base_url_save_load_roundtrip() {
1580        let temp = TempDir::new().unwrap();
1581        let original = Config {
1582            mode: ConfigMode::NextGen,
1583            export: ExportConfig {
1584                base_url: Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1585            },
1586            ..Default::default()
1587        };
1588
1589        original.save(temp.path()).unwrap();
1590        let loaded = Config::load(temp.path()).unwrap();
1591
1592        assert_eq!(
1593            loaded.export.base_url,
1594            Some("https://github.com/org/repo/blob/main/doc/adr".to_string()),
1595            "export.base_url should survive a save/load round-trip"
1596        );
1597    }
1598}