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/// Configuration for an ADR repository.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct Config {
20    /// The directory where ADRs are stored.
21    pub adr_dir: PathBuf,
22
23    /// The mode of operation.
24    pub mode: ConfigMode,
25
26    /// Template configuration.
27    #[serde(default)]
28    pub templates: TemplateConfig,
29}
30
31impl Default for Config {
32    fn default() -> Self {
33        Self {
34            adr_dir: PathBuf::from(DEFAULT_ADR_DIR),
35            mode: ConfigMode::Compatible,
36            templates: TemplateConfig::default(),
37        }
38    }
39}
40
41impl Config {
42    /// Load configuration from the given directory.
43    ///
44    /// Searches for configuration in the following order:
45    /// 1. `adrs.toml` (new format)
46    /// 2. `.adr-dir` (legacy adr-tools format)
47    /// 3. Default configuration
48    pub fn load(root: &Path) -> Result<Self> {
49        // Try new config first
50        let config_path = root.join(CONFIG_FILE);
51        if config_path.exists() {
52            let content = std::fs::read_to_string(&config_path)?;
53            let config: Config = toml::from_str(&content)?;
54            return Ok(config);
55        }
56
57        // Try legacy .adr-dir file
58        let legacy_path = root.join(LEGACY_CONFIG_FILE);
59        if legacy_path.exists() {
60            let adr_dir = std::fs::read_to_string(&legacy_path)?.trim().to_string();
61            return Ok(Self {
62                adr_dir: PathBuf::from(adr_dir),
63                mode: ConfigMode::Compatible,
64                templates: TemplateConfig::default(),
65            });
66        }
67
68        // Check if default directory exists
69        let default_dir = root.join(DEFAULT_ADR_DIR);
70        if default_dir.exists() {
71            return Ok(Self::default());
72        }
73
74        Err(Error::AdrDirNotFound)
75    }
76
77    /// Load configuration, or return default if not found.
78    pub fn load_or_default(root: &Path) -> Self {
79        Self::load(root).unwrap_or_default()
80    }
81
82    /// Save configuration to the given directory.
83    pub fn save(&self, root: &Path) -> Result<()> {
84        match self.mode {
85            ConfigMode::Compatible => {
86                // Write legacy .adr-dir file
87                let path = root.join(LEGACY_CONFIG_FILE);
88                std::fs::write(&path, self.adr_dir.display().to_string())?;
89            }
90            ConfigMode::NextGen => {
91                // Write adrs.toml
92                let path = root.join(CONFIG_FILE);
93                let content =
94                    toml::to_string_pretty(self).map_err(|e| Error::ConfigError(e.to_string()))?;
95                std::fs::write(&path, content)?;
96            }
97        }
98        Ok(())
99    }
100
101    /// Returns the full path to the ADR directory.
102    pub fn adr_path(&self, root: &Path) -> PathBuf {
103        root.join(&self.adr_dir)
104    }
105
106    /// Returns true if running in next-gen mode.
107    pub fn is_next_gen(&self) -> bool {
108        matches!(self.mode, ConfigMode::NextGen)
109    }
110}
111
112/// The mode of operation for the ADR tool.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum ConfigMode {
116    /// Compatible with adr-tools (markdown-only, no frontmatter).
117    #[default]
118    Compatible,
119
120    /// Next-gen mode with YAML frontmatter and enhanced features.
121    #[serde(rename = "ng")]
122    NextGen,
123}
124
125/// Template configuration.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(default)]
128pub struct TemplateConfig {
129    /// The default template format to use.
130    pub format: Option<String>,
131
132    /// Path to a custom template file.
133    pub custom: Option<PathBuf>,
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use tempfile::TempDir;
140    use test_case::test_case;
141
142    // ========== Default and Constants Tests ==========
143
144    #[test]
145    fn test_default_config() {
146        let config = Config::default();
147        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
148        assert_eq!(config.mode, ConfigMode::Compatible);
149        assert!(config.templates.format.is_none());
150        assert!(config.templates.custom.is_none());
151    }
152
153    #[test]
154    fn test_constants() {
155        assert_eq!(DEFAULT_ADR_DIR, "doc/adr");
156        assert_eq!(LEGACY_CONFIG_FILE, ".adr-dir");
157        assert_eq!(CONFIG_FILE, "adrs.toml");
158    }
159
160    #[test]
161    fn test_config_mode_default() {
162        assert_eq!(ConfigMode::default(), ConfigMode::Compatible);
163    }
164
165    // ========== Load Configuration Tests ==========
166
167    #[test]
168    fn test_load_legacy_config() {
169        let temp = TempDir::new().unwrap();
170        std::fs::write(temp.path().join(".adr-dir"), "decisions").unwrap();
171
172        let config = Config::load(temp.path()).unwrap();
173        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
174        assert_eq!(config.mode, ConfigMode::Compatible);
175    }
176
177    #[test]
178    fn test_load_legacy_config_with_whitespace() {
179        let temp = TempDir::new().unwrap();
180        std::fs::write(temp.path().join(".adr-dir"), "  decisions  \n").unwrap();
181
182        let config = Config::load(temp.path()).unwrap();
183        assert_eq!(config.adr_dir, PathBuf::from("decisions"));
184    }
185
186    #[test]
187    fn test_load_legacy_config_nested_path() {
188        let temp = TempDir::new().unwrap();
189        std::fs::write(temp.path().join(".adr-dir"), "docs/architecture/decisions").unwrap();
190
191        let config = Config::load(temp.path()).unwrap();
192        assert_eq!(config.adr_dir, PathBuf::from("docs/architecture/decisions"));
193    }
194
195    #[test]
196    fn test_load_new_config() {
197        let temp = TempDir::new().unwrap();
198        std::fs::write(
199            temp.path().join("adrs.toml"),
200            r#"
201adr_dir = "docs/decisions"
202mode = "ng"
203"#,
204        )
205        .unwrap();
206
207        let config = Config::load(temp.path()).unwrap();
208        assert_eq!(config.adr_dir, PathBuf::from("docs/decisions"));
209        assert_eq!(config.mode, ConfigMode::NextGen);
210    }
211
212    #[test]
213    fn test_load_new_config_compatible_mode() {
214        let temp = TempDir::new().unwrap();
215        std::fs::write(
216            temp.path().join("adrs.toml"),
217            r#"
218adr_dir = "doc/adr"
219mode = "compatible"
220"#,
221        )
222        .unwrap();
223
224        let config = Config::load(temp.path()).unwrap();
225        assert_eq!(config.mode, ConfigMode::Compatible);
226    }
227
228    #[test]
229    fn test_load_new_config_with_templates() {
230        let temp = TempDir::new().unwrap();
231        std::fs::write(
232            temp.path().join("adrs.toml"),
233            r#"
234adr_dir = "decisions"
235mode = "ng"
236
237[templates]
238format = "markdown"
239custom = "templates/adr.md"
240"#,
241        )
242        .unwrap();
243
244        let config = Config::load(temp.path()).unwrap();
245        assert_eq!(config.templates.format, Some("markdown".to_string()));
246        assert_eq!(
247            config.templates.custom,
248            Some(PathBuf::from("templates/adr.md"))
249        );
250    }
251
252    #[test]
253    fn test_load_new_config_minimal() {
254        let temp = TempDir::new().unwrap();
255        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "adrs""#).unwrap();
256
257        let config = Config::load(temp.path()).unwrap();
258        assert_eq!(config.adr_dir, PathBuf::from("adrs"));
259        // Should use defaults for missing fields
260        assert_eq!(config.mode, ConfigMode::Compatible);
261    }
262
263    #[test]
264    fn test_load_prefers_new_config_over_legacy() {
265        let temp = TempDir::new().unwrap();
266        // Create both config files
267        std::fs::write(temp.path().join(".adr-dir"), "legacy-dir").unwrap();
268        std::fs::write(temp.path().join("adrs.toml"), r#"adr_dir = "new-dir""#).unwrap();
269
270        let config = Config::load(temp.path()).unwrap();
271        // Should prefer adrs.toml
272        assert_eq!(config.adr_dir, PathBuf::from("new-dir"));
273    }
274
275    #[test]
276    fn test_load_default_dir_exists() {
277        let temp = TempDir::new().unwrap();
278        // Create the default directory
279        std::fs::create_dir_all(temp.path().join("doc/adr")).unwrap();
280
281        let config = Config::load(temp.path()).unwrap();
282        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
283    }
284
285    #[test]
286    fn test_load_no_config_no_default_dir() {
287        let temp = TempDir::new().unwrap();
288        // Empty directory - no config, no default dir
289
290        let result = Config::load(temp.path());
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_load_or_default_returns_default_on_error() {
296        let temp = TempDir::new().unwrap();
297        // Empty directory - would error with load()
298
299        let config = Config::load_or_default(temp.path());
300        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
301        assert_eq!(config.mode, ConfigMode::Compatible);
302    }
303
304    #[test]
305    fn test_load_or_default_returns_config_when_exists() {
306        let temp = TempDir::new().unwrap();
307        std::fs::write(temp.path().join(".adr-dir"), "custom-dir").unwrap();
308
309        let config = Config::load_or_default(temp.path());
310        assert_eq!(config.adr_dir, PathBuf::from("custom-dir"));
311    }
312
313    // ========== Save Configuration Tests ==========
314
315    #[test]
316    fn test_save_legacy_config() {
317        let temp = TempDir::new().unwrap();
318        let config = Config {
319            adr_dir: PathBuf::from("my/adrs"),
320            mode: ConfigMode::Compatible,
321            templates: TemplateConfig::default(),
322        };
323
324        config.save(temp.path()).unwrap();
325
326        let content = std::fs::read_to_string(temp.path().join(".adr-dir")).unwrap();
327        assert_eq!(content, "my/adrs");
328        // Should not create adrs.toml
329        assert!(!temp.path().join("adrs.toml").exists());
330    }
331
332    #[test]
333    fn test_save_new_config() {
334        let temp = TempDir::new().unwrap();
335        let config = Config {
336            adr_dir: PathBuf::from("docs/decisions"),
337            mode: ConfigMode::NextGen,
338            templates: TemplateConfig::default(),
339        };
340
341        config.save(temp.path()).unwrap();
342
343        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
344        assert!(content.contains("docs/decisions"));
345        assert!(content.contains("ng"));
346        // Should not create .adr-dir
347        assert!(!temp.path().join(".adr-dir").exists());
348    }
349
350    #[test]
351    fn test_save_new_config_with_templates() {
352        let temp = TempDir::new().unwrap();
353        let config = Config {
354            adr_dir: PathBuf::from("decisions"),
355            mode: ConfigMode::NextGen,
356            templates: TemplateConfig {
357                format: Some("custom".to_string()),
358                custom: Some(PathBuf::from("my-template.md")),
359            },
360        };
361
362        config.save(temp.path()).unwrap();
363
364        let content = std::fs::read_to_string(temp.path().join("adrs.toml")).unwrap();
365        assert!(content.contains("custom"));
366        assert!(content.contains("my-template.md"));
367    }
368
369    #[test]
370    fn test_save_and_load_roundtrip_compatible() {
371        let temp = TempDir::new().unwrap();
372        let original = Config {
373            adr_dir: PathBuf::from("architecture/decisions"),
374            mode: ConfigMode::Compatible,
375            templates: TemplateConfig::default(),
376        };
377
378        original.save(temp.path()).unwrap();
379        let loaded = Config::load(temp.path()).unwrap();
380
381        assert_eq!(loaded.adr_dir, original.adr_dir);
382        assert_eq!(loaded.mode, ConfigMode::Compatible);
383    }
384
385    #[test]
386    fn test_save_and_load_roundtrip_nextgen() {
387        let temp = TempDir::new().unwrap();
388        let original = Config {
389            adr_dir: PathBuf::from("docs/adr"),
390            mode: ConfigMode::NextGen,
391            templates: TemplateConfig {
392                format: Some("markdown".to_string()),
393                custom: None,
394            },
395        };
396
397        original.save(temp.path()).unwrap();
398        let loaded = Config::load(temp.path()).unwrap();
399
400        assert_eq!(loaded.adr_dir, original.adr_dir);
401        assert_eq!(loaded.mode, ConfigMode::NextGen);
402        assert_eq!(loaded.templates.format, Some("markdown".to_string()));
403    }
404
405    // ========== Helper Method Tests ==========
406
407    #[test_case("doc/adr", "/project" => PathBuf::from("/project/doc/adr"); "default path")]
408    #[test_case("decisions", "/home/user/repo" => PathBuf::from("/home/user/repo/decisions"); "simple path")]
409    #[test_case("docs/architecture/decisions", "/repo" => PathBuf::from("/repo/docs/architecture/decisions"); "nested path")]
410    fn test_adr_path(adr_dir: &str, root: &str) -> PathBuf {
411        let config = Config {
412            adr_dir: PathBuf::from(adr_dir),
413            ..Default::default()
414        };
415        config.adr_path(Path::new(root))
416    }
417
418    #[test]
419    fn test_is_next_gen() {
420        let compatible = Config {
421            mode: ConfigMode::Compatible,
422            ..Default::default()
423        };
424        assert!(!compatible.is_next_gen());
425
426        let nextgen = Config {
427            mode: ConfigMode::NextGen,
428            ..Default::default()
429        };
430        assert!(nextgen.is_next_gen());
431    }
432
433    // ========== ConfigMode Tests ==========
434
435    #[test]
436    fn test_config_mode_equality() {
437        assert_eq!(ConfigMode::Compatible, ConfigMode::Compatible);
438        assert_eq!(ConfigMode::NextGen, ConfigMode::NextGen);
439        assert_ne!(ConfigMode::Compatible, ConfigMode::NextGen);
440    }
441
442    #[test]
443    fn test_config_mode_serialization_in_config() {
444        // TOML requires enums to be serialized within a struct
445        let config = Config {
446            mode: ConfigMode::Compatible,
447            ..Default::default()
448        };
449        let toml = toml::to_string(&config).unwrap();
450        assert!(toml.contains("mode = \"compatible\""));
451
452        let config = Config {
453            mode: ConfigMode::NextGen,
454            ..Default::default()
455        };
456        let toml = toml::to_string(&config).unwrap();
457        assert!(toml.contains("mode = \"ng\""));
458    }
459
460    #[test]
461    fn test_config_mode_deserialization_in_config() {
462        let config: Config = toml::from_str(r#"mode = "compatible""#).unwrap();
463        assert_eq!(config.mode, ConfigMode::Compatible);
464
465        let config: Config = toml::from_str(r#"mode = "ng""#).unwrap();
466        assert_eq!(config.mode, ConfigMode::NextGen);
467    }
468
469    // ========== TemplateConfig Tests ==========
470
471    #[test]
472    fn test_template_config_default() {
473        let config = TemplateConfig::default();
474        assert!(config.format.is_none());
475        assert!(config.custom.is_none());
476    }
477
478    #[test]
479    fn test_template_config_serialization() {
480        let config = TemplateConfig {
481            format: Some("nygard".to_string()),
482            custom: Some(PathBuf::from("templates/custom.md")),
483        };
484
485        let toml = toml::to_string(&config).unwrap();
486        assert!(toml.contains("nygard"));
487        assert!(toml.contains("templates/custom.md"));
488    }
489
490    // ========== Error Cases ==========
491
492    #[test]
493    fn test_load_invalid_toml() {
494        let temp = TempDir::new().unwrap();
495        std::fs::write(temp.path().join("adrs.toml"), "this is not valid toml {{{").unwrap();
496
497        let result = Config::load(temp.path());
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn test_load_empty_toml() {
503        let temp = TempDir::new().unwrap();
504        std::fs::write(temp.path().join("adrs.toml"), "").unwrap();
505
506        // Empty TOML should use defaults
507        let config = Config::load(temp.path()).unwrap();
508        assert_eq!(config.adr_dir, PathBuf::from("doc/adr"));
509    }
510
511    #[test]
512    fn test_load_empty_adr_dir_file() {
513        let temp = TempDir::new().unwrap();
514        std::fs::write(temp.path().join(".adr-dir"), "").unwrap();
515
516        let config = Config::load(temp.path()).unwrap();
517        // Empty string becomes empty path
518        assert_eq!(config.adr_dir, PathBuf::from(""));
519    }
520}