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 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 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 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 let d = Config::default();
146 assert_eq!(d.adr_dir, PathBuf::from("docs/adr"));
147 assert_eq!(d.index_name, "index.md");
148 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 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}