1use std::path::{Path, PathBuf};
38
39use ijima_core::{IjimaError, Result};
40use serde::Deserialize;
41
42#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
45pub struct IjimaToml {
46 pub host: Option<String>,
48 pub port: Option<u16>,
50 pub data_dir: Option<String>,
53 pub issuer_key: Option<String>,
55 pub rate_base: Option<f64>,
58 pub rate_multiplier: Option<f64>,
60 pub embedding_model: Option<String>,
63}
64
65pub fn discover_path() -> Option<PathBuf> {
71 if let Ok(explicit) = std::env::var("IJIMA_CONFIG") {
72 return Some(PathBuf::from(explicit));
73 }
74 if let Some(dir) = data_dir_implicit() {
75 let p = dir.join("ijima.toml");
76 if p.exists() {
77 return Some(p);
78 }
79 }
80 let etc = Path::new("/etc/ijima/ijima.toml");
81 etc.exists().then(|| etc.to_path_buf())
82}
83
84pub fn load() -> Result<IjimaToml> {
92 let Some(path) = discover_path() else {
93 return Ok(IjimaToml::default());
94 };
95 let text = std::fs::read_to_string(&path).map_err(|e| {
96 IjimaError::invalid_input(format!("config file {} unreadable: {e}", path.display()))
97 })?;
98 toml::from_str(&text).map_err(|e| {
99 IjimaError::invalid_input(format!("config file {} malformed: {e}", path.display()))
100 })
101}
102
103fn data_dir_implicit() -> Option<PathBuf> {
106 if let Ok(dir) = std::env::var("IJIMA_DIR") {
107 return Some(PathBuf::from(dir));
108 }
109 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".ijima"))
110}
111
112pub fn resolve_data_dir() -> Result<PathBuf> {
120 if let Ok(dir) = std::env::var("IJIMA_DIR") {
121 return Ok(PathBuf::from(dir));
122 }
123 if let Some(dir) = load().ok().and_then(|c| c.data_dir) {
124 return Ok(PathBuf::from(dir));
125 }
126 data_dir_implicit()
127 .ok_or_else(|| IjimaError::invalid_input("cannot resolve data dir: set IJIMA_DIR or HOME"))
128}
129
130pub fn resolve_str(env_key: &str, file: Option<String>, default: &str) -> String {
132 std::env::var(env_key).unwrap_or_else(|_| file.unwrap_or_else(|| default.to_string()))
133}
134
135pub fn resolve_path(env_key: &str, file: Option<String>) -> Option<PathBuf> {
137 std::env::var_os(env_key)
138 .map(PathBuf::from)
139 .or_else(|| file.map(PathBuf::from))
140}
141
142pub fn resolve_f64(env_key: &str, file: Option<f64>, default: f64) -> f64 {
145 std::env::var(env_key)
146 .ok()
147 .and_then(|v| v.parse().ok())
148 .unwrap_or(file.unwrap_or(default))
149}
150
151#[cfg(test)]
157#[allow(unsafe_code)]
158mod tests {
159 use super::*;
160
161 fn with_env(f: impl FnOnce(&EnvVars)) {
166 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
167 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
168 const VARS: [&str; 3] = ["IJIMA_CONFIG", "IJIMA_DIR", "HOME"];
169 let saved: Vec<Option<String>> = VARS.iter().map(|k| std::env::var(k).ok()).collect();
170 for k in VARS {
172 unsafe { std::env::remove_var(k) };
173 }
174 let env = EnvVars;
175 f(&env);
176 for (k, v) in VARS.iter().zip(saved) {
178 match v {
179 Some(val) => unsafe { std::env::set_var(k, val) },
180 None => unsafe { std::env::remove_var(k) },
181 }
182 }
183 }
184
185 struct EnvVars;
187
188 impl EnvVars {
189 fn set(&self, key: &str, value: &str) {
190 unsafe { std::env::set_var(key, value) };
192 }
193 }
194
195 fn write_config(dir: &Path, body: &str) -> PathBuf {
196 std::fs::create_dir_all(dir).unwrap();
197 let p = dir.join("ijima.toml");
198 std::fs::write(&p, body).unwrap();
199 p
200 }
201
202 #[test]
203 fn no_config_anywhere_yields_defaults() {
204 with_env(|_| {
205 assert_eq!(load().unwrap(), IjimaToml::default());
206 });
207 }
208
209 #[test]
210 fn explicit_config_env_wins_and_parses() {
211 with_env(|env| {
212 let dir = std::env::temp_dir().join(format!("ijima-cfg-{}", std::process::id()));
213 let p = write_config(&dir, "host = \"0.0.0.0\"\nport = 8000\nrate_base = 5.5\n");
214 env.set("IJIMA_CONFIG", p.to_str().unwrap());
215
216 let cfg = load().unwrap();
217 assert_eq!(cfg.host.as_deref(), Some("0.0.0.0"));
218 assert_eq!(cfg.port, Some(8000));
219 assert_eq!(cfg.rate_base, Some(5.5));
220 assert_eq!(cfg.data_dir, None); });
222 }
223
224 #[test]
225 fn unknown_keys_are_ignored() {
226 with_env(|env| {
227 let dir = std::env::temp_dir().join(format!("ijima-cfg2-{}", std::process::id()));
228 let p = write_config(&dir, "future_key = true\nhost = \"h\"\n");
229 env.set("IJIMA_CONFIG", p.to_str().unwrap());
230 assert_eq!(load().unwrap().host.as_deref(), Some("h"));
231 });
232 }
233
234 #[test]
235 fn malformed_config_is_a_hard_error() {
236 with_env(|env| {
237 let dir = std::env::temp_dir().join(format!("ijima-cfg3-{}", std::process::id()));
238 let p = write_config(&dir, "port = [not a number");
239 env.set("IJIMA_CONFIG", p.to_str().unwrap());
240 assert!(load().is_err());
241 });
242 }
243
244 #[test]
245 fn explicit_config_missing_file_is_an_error() {
246 with_env(|env| {
247 env.set("IJIMA_CONFIG", "/nonexistent/ijima.toml");
248 assert!(load().is_err());
249 });
250 }
251
252 #[test]
253 fn implicit_config_in_ijima_dir_is_discovered() {
254 with_env(|env| {
255 let dir = std::env::temp_dir().join(format!("ijima-cfg4-{}", std::process::id()));
256 write_config(&dir, "host = \"file-host\"\n");
257 env.set("IJIMA_DIR", dir.to_str().unwrap());
258 assert_eq!(load().unwrap().host.as_deref(), Some("file-host"));
259 });
260 }
261
262 #[test]
263 fn data_dir_env_beats_file_beats_home() {
264 with_env(|env| {
265 let dir = std::env::temp_dir().join(format!("ijima-cfg5-{}", std::process::id()));
266 let p = write_config(&dir, "data_dir = \"/from-file\"\n");
267 env.set("IJIMA_CONFIG", p.to_str().unwrap());
268 env.set("HOME", "/home/tester");
269
270 assert_eq!(resolve_data_dir().unwrap(), PathBuf::from("/from-file"));
272
273 env.set("IJIMA_DIR", "/from-env");
275 assert_eq!(resolve_data_dir().unwrap(), PathBuf::from("/from-env"));
276 });
277 }
278
279 #[test]
280 fn data_dir_home_fallback() {
281 with_env(|env| {
282 env.set("HOME", "/home/tester");
283 assert_eq!(
284 resolve_data_dir().unwrap(),
285 PathBuf::from("/home/tester/.ijima")
286 );
287 });
288 }
289
290 #[test]
291 fn resolve_f64_env_beats_file_beats_default() {
292 assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", Some(2.5), 1.0), 2.5);
293 assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", None, 1.0), 1.0);
294 unsafe { std::env::set_var("IJIMA_NOPE_XYZ", "not-a-number") };
297 assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", Some(2.5), 1.0), 2.5);
298 unsafe { std::env::remove_var("IJIMA_NOPE_XYZ") };
299 }
300}