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