radr/
config.rs

1use std::{env, ffi::OsStr, fs, path::PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4use serde::Deserialize;
5
6#[derive(Debug, Clone)]
7pub struct Config {
8    pub adr_dir: PathBuf,
9    pub index_name: String,
10    pub template: Option<PathBuf>,
11}
12
13impl Default for Config {
14    fn default() -> Self {
15        Self {
16            adr_dir: PathBuf::from("docs/adr"),
17            index_name: "index.md".to_string(),
18            template: None,
19        }
20    }
21}
22
23#[derive(Deserialize, Debug)]
24struct FileConfig {
25    adr_dir: Option<PathBuf>,
26    index_name: Option<String>,
27    template: Option<PathBuf>,
28}
29
30pub fn load_config(cli_path: Option<&PathBuf>) -> Result<Config> {
31    let mut cfg = Config::default();
32
33    let path = if let Some(p) = cli_path {
34        Some(p.clone())
35    } else if let Ok(env_p) = env::var("RADR_CONFIG") {
36        Some(PathBuf::from(env_p))
37    } else {
38        let candidates = [
39            "radr.toml",
40            "radr.yaml",
41            "radr.yml",
42            "radr.json",
43            ".radrrc.toml",
44            ".radrrc.yaml",
45            ".radrrc.yml",
46            ".radrrc.json",
47        ];
48        candidates.iter().map(PathBuf::from).find(|p| p.exists())
49    };
50
51    if let Some(p) = path {
52        let ext = p.extension().and_then(OsStr::to_str).unwrap_or("");
53        let contents =
54            fs::read_to_string(&p).with_context(|| format!("Reading config at {}", p.display()))?;
55        let fc: FileConfig = match ext.to_ascii_lowercase().as_str() {
56            "json" => serde_json::from_str(&contents)
57                .with_context(|| format!("Parsing JSON config at {}", p.display()))?,
58            "yaml" | "yml" => serde_yaml::from_str(&contents)
59                .with_context(|| format!("Parsing YAML config at {}", p.display()))?,
60            "toml" => toml::from_str(&contents)
61                .with_context(|| format!("Parsing TOML config at {}", p.display()))?,
62            other => return Err(anyhow!("Unsupported config extension: {}", other)),
63        };
64
65        if let Some(d) = fc.adr_dir {
66            cfg.adr_dir = d;
67        }
68        if let Some(i) = fc.index_name {
69            cfg.index_name = i;
70        }
71        if let Some(t) = fc.template {
72            cfg.template = Some(t);
73        }
74    }
75
76    Ok(cfg)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::io::Write;
83    use tempfile::tempdir;
84
85    #[test]
86    fn test_default_config() {
87        let c = Config::default();
88        assert_eq!(c.index_name, "index.md");
89    }
90
91    #[test]
92    fn test_load_from_toml() {
93        let dir = tempdir().unwrap();
94        let path = dir.path().join("radr.toml");
95        let mut f = std::fs::File::create(&path).unwrap();
96        writeln!(f, "adr_dir='adrs'\nindex_name='IDX.md'").unwrap();
97        std::env::set_current_dir(dir.path()).unwrap();
98        let cfg = load_config(None).unwrap();
99        assert_eq!(cfg.adr_dir, PathBuf::from("adrs"));
100        assert_eq!(cfg.index_name, "IDX.md");
101    }
102
103    #[test]
104    fn test_cli_over_env_precedence_and_template() {
105        let dir = tempdir().unwrap();
106        let json = dir.path().join("radr.json");
107        let yaml = dir.path().join("radr.yaml");
108        let tpl = dir.path().join("tpl.md");
109        std::fs::write(&tpl, "T").unwrap();
110        std::fs::write(&json, b"{\n  \"adr_dir\": \"cli_adrs\",\n  \"index_name\": \"CLI.md\",\n  \"template\": \"tpl.md\"\n}\n").unwrap();
111        std::fs::write(&yaml, b"adr_dir: env_adrs\nindex_name: ENV.md\n").unwrap();
112        // Set env to YAML, but pass CLI JSON path; CLI should win
113        std::env::set_var("RADR_CONFIG", &yaml);
114        let cfg = load_config(Some(&json)).unwrap();
115        assert_eq!(cfg.adr_dir, PathBuf::from("cli_adrs"));
116        assert_eq!(cfg.index_name, "CLI.md");
117        assert_eq!(
118            cfg.template.as_deref(),
119            Some(PathBuf::from("tpl.md").as_path())
120        );
121        std::env::remove_var("RADR_CONFIG");
122    }
123
124    #[test]
125    fn test_unsupported_extension_errors() {
126        let dir = tempdir().unwrap();
127        let bad = dir.path().join("radr.txt");
128        std::fs::write(&bad, "adr_dir=adrs").unwrap();
129        let err = load_config(Some(&bad)).unwrap_err();
130        let msg = format!("{}", err);
131        assert!(msg.contains("Unsupported config extension"));
132    }
133
134    #[test]
135    fn test_env_over_local_and_defaults() {
136        let dir = tempdir().unwrap();
137        // Write local toml
138        let toml_path = dir.path().join("radr.toml");
139        let mut f = std::fs::File::create(&toml_path).unwrap();
140        writeln!(f, "adr_dir='local'\nindex_name='LOCAL.md'").unwrap();
141        // Write env yaml
142        let yaml_path = dir.path().join("radr.yaml");
143        std::fs::write(&yaml_path, b"adr_dir: env\nindex_name: ENV.md\n").unwrap();
144        // defaults before setting cwd/env
145        let d = Config::default();
146        assert_eq!(d.adr_dir, PathBuf::from("docs/adr"));
147        assert_eq!(d.index_name, "index.md");
148        // Now set cwd and env; env should win when no CLI provided
149        std::env::set_current_dir(dir.path()).unwrap();
150        std::env::set_var("RADR_CONFIG", yaml_path.to_str().unwrap());
151        let cfg = load_config(None).unwrap();
152        assert_eq!(cfg.adr_dir, PathBuf::from("env"));
153        assert_eq!(cfg.index_name, "ENV.md");
154        std::env::remove_var("RADR_CONFIG");
155    }
156
157    #[test]
158    fn test_invalid_config_content_errors() {
159        let dir = tempdir().unwrap();
160        let bad_toml = dir.path().join("radr.toml");
161        // invalid toml (missing equals)
162        std::fs::write(&bad_toml, "adr_dir 'oops'").unwrap();
163        let err = load_config(Some(&bad_toml)).unwrap_err();
164        let msg = format!("{}", err);
165        assert!(msg.contains("Parsing TOML config"));
166    }
167}