Skip to main content

ijima_server/
config.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Layered configuration for the Ijima daemon and CLI.
5//!
6//! Resolution order, lowest to highest:
7//!
8//! ```text
9//! built-in defaults  <  ijima.toml (file)  <  env vars  <  CLI flags
10//! ```
11//!
12//! ## File discovery
13//!
14//! The first existing path wins:
15//!
16//! 1. `$IJIMA_CONFIG` (explicit path — errors if unreadable/malformed)
17//! 2. `$IJIMA_DIR/ijima.toml` (or `~/.ijima/ijima.toml`)
18//! 3. `/etc/ijima/ijima.toml`
19//!
20//! Unknown keys in the file are ignored (forward compatibility). An
21//! explicit `$IJIMA_CONFIG` that cannot be parsed is a hard error — a
22//! deployment that points at a config expects it to be honored.
23//!
24//! ## Example
25//!
26//! ```toml
27//! # /etc/ijima/ijima.toml
28//! host = "127.0.0.1"
29//! port = 7373
30//! data_dir = "/var/lib/ijima"
31//! issuer_key = "/var/lib/ijima/issuer.key"
32//! rate_base = 10.0
33//! rate_multiplier = 1.0
34//! embedding_model = "sentence-transformers/all-MiniLM-L6-v2"
35//! ```
36
37use std::path::{Path, PathBuf};
38
39use ijima_core::{IjimaError, Result};
40use serde::Deserialize;
41
42/// The file layer of Ijima configuration (`ijima.toml`). Every field is
43/// optional — absent fields fall through to env/defaults.
44#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
45pub struct IjimaToml {
46    /// Bind host (env `IJIMA_HOST`, default `127.0.0.1`).
47    pub host: Option<String>,
48    /// Bind port (env `IJIMA_PORT`, default `7373`).
49    pub port: Option<u16>,
50    /// Data directory — where the SurrealDB store and issuer key live
51    /// (env `IJIMA_DIR`, default `~/.ijima`).
52    pub data_dir: Option<String>,
53    /// Issuer key path (default `<data_dir>/issuer.key`).
54    pub issuer_key: Option<String>,
55    /// Rate-limit base tokens/sec per intersection-number unit
56    /// (env `IJIMA_RATE_BASE`, default `10`).
57    pub rate_base: Option<f64>,
58    /// Rate-limit multiplier (env `IJIMA_RATE_MULTIPLIER`, default `1.0`).
59    pub rate_multiplier: Option<f64>,
60    /// Hugging Face model id for candle embeddings
61    /// (default `sentence-transformers/all-MiniLM-L6-v2`).
62    pub embedding_model: Option<String>,
63}
64
65/// Returns the config file path if one exists, per the discovery order.
66///
67/// `$IJIMA_CONFIG` wins even if the file does not exist (an explicit
68/// pointer means the operator expects it); the implicit fallbacks only
69/// apply when it is unset.
70pub 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
84/// Loads and parses the config file, if any exists.
85///
86/// # Errors
87///
88/// - [`IjimaError::InvalidInput`] if an existing file cannot be read or
89///   parsed (including an explicit `$IJIMA_CONFIG` pointing at a missing
90///   file — an explicit pointer must be honored, not silently skipped).
91pub 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
103/// The implicit data dir (no env, no file layer): `$IJIMA_DIR` or
104/// `~/.ijima`. Used for file discovery before the file is parsed.
105fn 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
112/// Resolves the data directory: env `IJIMA_DIR` > config file `data_dir`
113/// > `~/.ijima`.
114///
115/// # Errors
116///
117/// Returns [`IjimaError::InvalidInput`] if neither env var, config file,
118/// nor `HOME` can resolve a directory.
119pub 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
130/// Resolves a string setting: env var > config field > default.
131pub 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
135/// Resolves an optional-path setting: env var > config field > `None`.
136pub 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
142/// Resolves an `f64` setting: env var > config field > default. Malformed
143/// env values fall through to the next layer.
144pub 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// SAFETY Justification for `allow(unsafe_code)` in tests: Rust 2024
152// marks `set_var`/`remove_var` unsafe because unsynchronized env mutation
153// from multiple threads is UB. Every env mutation here happens inside
154// `with_env`, whose static mutex serializes all such tests; other tests in
155// this crate never read these three vars. Sound under that invariant.
156#[cfg(test)]
157#[allow(unsafe_code)]
158mod tests {
159    use super::*;
160
161    /// Runs `f` with `IJIMA_CONFIG`/`IJIMA_DIR`/`HOME` isolated: saved &
162    /// removed before, restored after. The env-mutex guard lives in this
163    /// function's frame for the whole test body, serializing all
164    /// env-touching tests (they run in parallel otherwise).
165    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        // SAFETY: serialized by ENV_LOCK (see module comment).
171        for k in VARS {
172            unsafe { std::env::remove_var(k) };
173        }
174        let env = EnvVars;
175        f(&env);
176        // SAFETY: serialized by ENV_LOCK (see module comment).
177        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    /// Handle for setting isolated env vars inside [`with_env`].
186    struct EnvVars;
187
188    impl EnvVars {
189        fn set(&self, key: &str, value: &str) {
190            // SAFETY: serialized by with_env's ENV_LOCK (see module comment).
191            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); // unset fields stay None
221        });
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            // file layer beats home
271            assert_eq!(resolve_data_dir().unwrap(), PathBuf::from("/from-file"));
272
273            // env layer beats file
274            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        // malformed env falls through to file. SAFETY: unique env key
295        // touched only by this test; other tests never read it.
296        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}