use std::path::{Path, PathBuf};
use ijima_core::{IjimaError, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct IjimaToml {
pub host: Option<String>,
pub port: Option<u16>,
pub data_dir: Option<String>,
pub issuer_key: Option<String>,
pub rate_base: Option<f64>,
pub rate_multiplier: Option<f64>,
pub embedding_model: Option<String>,
}
pub fn discover_path() -> Option<PathBuf> {
if let Ok(explicit) = std::env::var("IJIMA_CONFIG") {
return Some(PathBuf::from(explicit));
}
if let Some(dir) = data_dir_implicit() {
let p = dir.join("ijima.toml");
if p.exists() {
return Some(p);
}
}
let etc = Path::new("/etc/ijima/ijima.toml");
etc.exists().then(|| etc.to_path_buf())
}
pub fn load() -> Result<IjimaToml> {
let Some(path) = discover_path() else {
return Ok(IjimaToml::default());
};
let text = std::fs::read_to_string(&path).map_err(|e| {
IjimaError::invalid_input(format!("config file {} unreadable: {e}", path.display()))
})?;
toml::from_str(&text).map_err(|e| {
IjimaError::invalid_input(format!("config file {} malformed: {e}", path.display()))
})
}
fn data_dir_implicit() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("IJIMA_DIR") {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".ijima"))
}
pub fn resolve_data_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("IJIMA_DIR") {
return Ok(PathBuf::from(dir));
}
if let Some(dir) = load().ok().and_then(|c| c.data_dir) {
return Ok(PathBuf::from(dir));
}
data_dir_implicit()
.ok_or_else(|| IjimaError::invalid_input("cannot resolve data dir: set IJIMA_DIR or HOME"))
}
pub fn resolve_str(env_key: &str, file: Option<String>, default: &str) -> String {
std::env::var(env_key).unwrap_or_else(|_| file.unwrap_or_else(|| default.to_string()))
}
pub fn resolve_path(env_key: &str, file: Option<String>) -> Option<PathBuf> {
std::env::var_os(env_key)
.map(PathBuf::from)
.or_else(|| file.map(PathBuf::from))
}
pub fn resolve_f64(env_key: &str, file: Option<f64>, default: f64) -> f64 {
std::env::var(env_key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(file.unwrap_or(default))
}
#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
fn with_env(f: impl FnOnce(&EnvVars)) {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
const VARS: [&str; 3] = ["IJIMA_CONFIG", "IJIMA_DIR", "HOME"];
let saved: Vec<Option<String>> = VARS.iter().map(|k| std::env::var(k).ok()).collect();
for k in VARS {
unsafe { std::env::remove_var(k) };
}
let env = EnvVars;
f(&env);
for (k, v) in VARS.iter().zip(saved) {
match v {
Some(val) => unsafe { std::env::set_var(k, val) },
None => unsafe { std::env::remove_var(k) },
}
}
}
struct EnvVars;
impl EnvVars {
fn set(&self, key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
}
fn write_config(dir: &Path, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let p = dir.join("ijima.toml");
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn no_config_anywhere_yields_defaults() {
with_env(|_| {
assert_eq!(load().unwrap(), IjimaToml::default());
});
}
#[test]
fn explicit_config_env_wins_and_parses() {
with_env(|env| {
let dir = std::env::temp_dir().join(format!("ijima-cfg-{}", std::process::id()));
let p = write_config(&dir, "host = \"0.0.0.0\"\nport = 8000\nrate_base = 5.5\n");
env.set("IJIMA_CONFIG", p.to_str().unwrap());
let cfg = load().unwrap();
assert_eq!(cfg.host.as_deref(), Some("0.0.0.0"));
assert_eq!(cfg.port, Some(8000));
assert_eq!(cfg.rate_base, Some(5.5));
assert_eq!(cfg.data_dir, None); });
}
#[test]
fn unknown_keys_are_ignored() {
with_env(|env| {
let dir = std::env::temp_dir().join(format!("ijima-cfg2-{}", std::process::id()));
let p = write_config(&dir, "future_key = true\nhost = \"h\"\n");
env.set("IJIMA_CONFIG", p.to_str().unwrap());
assert_eq!(load().unwrap().host.as_deref(), Some("h"));
});
}
#[test]
fn malformed_config_is_a_hard_error() {
with_env(|env| {
let dir = std::env::temp_dir().join(format!("ijima-cfg3-{}", std::process::id()));
let p = write_config(&dir, "port = [not a number");
env.set("IJIMA_CONFIG", p.to_str().unwrap());
assert!(load().is_err());
});
}
#[test]
fn explicit_config_missing_file_is_an_error() {
with_env(|env| {
env.set("IJIMA_CONFIG", "/nonexistent/ijima.toml");
assert!(load().is_err());
});
}
#[test]
fn implicit_config_in_ijima_dir_is_discovered() {
with_env(|env| {
let dir = std::env::temp_dir().join(format!("ijima-cfg4-{}", std::process::id()));
write_config(&dir, "host = \"file-host\"\n");
env.set("IJIMA_DIR", dir.to_str().unwrap());
assert_eq!(load().unwrap().host.as_deref(), Some("file-host"));
});
}
#[test]
fn data_dir_env_beats_file_beats_home() {
with_env(|env| {
let dir = std::env::temp_dir().join(format!("ijima-cfg5-{}", std::process::id()));
let p = write_config(&dir, "data_dir = \"/from-file\"\n");
env.set("IJIMA_CONFIG", p.to_str().unwrap());
env.set("HOME", "/home/tester");
assert_eq!(resolve_data_dir().unwrap(), PathBuf::from("/from-file"));
env.set("IJIMA_DIR", "/from-env");
assert_eq!(resolve_data_dir().unwrap(), PathBuf::from("/from-env"));
});
}
#[test]
fn data_dir_home_fallback() {
with_env(|env| {
env.set("HOME", "/home/tester");
assert_eq!(
resolve_data_dir().unwrap(),
PathBuf::from("/home/tester/.ijima")
);
});
}
#[test]
fn resolve_f64_env_beats_file_beats_default() {
assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", Some(2.5), 1.0), 2.5);
assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", None, 1.0), 1.0);
unsafe { std::env::set_var("IJIMA_NOPE_XYZ", "not-a-number") };
assert_eq!(resolve_f64("IJIMA_NOPE_XYZ", Some(2.5), 1.0), 2.5);
unsafe { std::env::remove_var("IJIMA_NOPE_XYZ") };
}
}