Skip to main content

sqlite_graphrag/
runtime_config.rs

1//! Runtime configuration resolved without product environment variables.
2//!
3//! Precedence (G-T-XDG-04 / plan v4): **CLI flag > XDG `config set` > named default**.
4//! Product `SQLITE_GRAPHRAG_*` / `OPENROUTER_*` env vars are **not** read for config.
5//! OS env allowed only for process identity: `HOME`, `PATH`, `XDG_*`, locale, `NO_COLOR`.
6
7use crate::config;
8use std::sync::OnceLock;
9
10/// Process-wide overrides captured once from CLI flags at bootstrap.
11#[derive(Debug, Clone, Default)]
12pub struct RuntimeOverrides {
13    /// Embedding dim.
14    pub embedding_dim: Option<u32>,
15    /// Claude binary.
16    pub claude_binary: Option<String>,
17    /// Codex binary.
18    pub codex_binary: Option<String>,
19    /// Opencode binary.
20    pub opencode_binary: Option<String>, // path as string
21    /// LLM model.
22    pub llm_model: Option<String>,
23    /// LLM fallback.
24    pub llm_fallback: Option<String>,
25    /// Skip embedding on failure.
26    pub skip_embedding_on_failure: bool,
27    /// LLM max host concurrency.
28    pub llm_max_host_concurrency: Option<usize>,
29    /// LLM slot wait secs.
30    pub llm_slot_wait_secs: Option<u64>,
31    /// LLM slot no wait.
32    pub llm_slot_no_wait: bool,
33    /// Strict ENV clear.
34    pub strict_env_clear: bool,
35    /// Log level.
36    pub log_level: Option<String>,
37    /// Log format.
38    pub log_format: Option<String>,
39    /// Lang.
40    pub lang: Option<String>,
41    /// Display TZ.
42    pub display_tz: Option<String>,
43    /// DB path.
44    pub db_path: Option<String>,
45}
46
47/// Directory overrides installed BEFORE anything reads `config.toml`.
48///
49/// These live in their own `OnceLock` because of an ordering hazard: `main`
50/// resolves the interface language during a pre-parse pass that runs before
51/// [`init`], and language resolution reads the XDG key `i18n.lang` — which
52/// means it reads `config.toml`, which means it needs `--config-dir` already.
53/// Folding these into [`RuntimeOverrides`] would force [`init`] to run first,
54/// and since both are first-wins `OnceLock`s the later call would be dropped.
55#[derive(Debug, Clone, Default)]
56pub struct PathOverrides {
57    /// CLI `--config-dir`: directory holding `config.toml`.
58    pub config_dir: Option<String>,
59    /// CLI `--cache-dir`: root for lock files, models and cache artifacts.
60    pub cache_dir: Option<String>,
61}
62
63static PATHS: OnceLock<PathOverrides> = OnceLock::new();
64
65/// Install directory overrides. Idempotent first-wins.
66///
67/// MUST be called before any code path that can read `config.toml`.
68pub fn init_paths(overrides: PathOverrides) {
69    let _ = PATHS.set(overrides);
70}
71
72fn paths() -> PathOverrides {
73    PATHS.get().cloned().unwrap_or_default()
74}
75
76static RUNTIME: OnceLock<RuntimeOverrides> = OnceLock::new();
77
78/// Install CLI-captured overrides. Idempotent first-wins (main bootstrap).
79pub fn init(overrides: RuntimeOverrides) {
80    let _ = RUNTIME.set(overrides);
81}
82
83/// Borrow installed overrides (empty defaults if init was skipped — tests).
84pub fn get() -> RuntimeOverrides {
85    RUNTIME.get().cloned().unwrap_or_default()
86}
87
88/// CLI `--config-dir` only.
89///
90/// Deliberately does NOT consult [`config::get_setting`]: the config file lives
91/// inside the directory this function resolves, so reading it here would be
92/// circular. The XDG default is applied by [`crate::paths::config_dir`].
93pub fn config_dir_override() -> Option<String> {
94    paths()
95        .config_dir
96        .map(|s| s.trim().to_string())
97        .filter(|s| !s.is_empty())
98}
99
100/// CLI `--cache-dir` > XDG `cache.dir` > `None` (caller applies the OS default).
101pub fn cache_dir_override() -> Option<String> {
102    if let Some(v) = paths().cache_dir {
103        if !v.trim().is_empty() {
104            return Some(v.trim().to_string());
105        }
106    }
107    config::get_setting("cache.dir")
108        .ok()
109        .flatten()
110        .map(|s| s.trim().to_string())
111        .filter(|s| !s.is_empty())
112}
113
114/// flag_opt > XDG setting > default.
115pub fn resolve_string(flag: Option<&str>, xdg_key: &str, default: &str) -> String {
116    if let Some(v) = flag {
117        if !v.is_empty() {
118            return v.to_string();
119        }
120    }
121    if let Ok(Some(v)) = config::get_setting(xdg_key) {
122        if !v.is_empty() {
123            return v;
124        }
125    }
126    default.to_string()
127}
128
129/// flag_opt > XDG setting > None.
130pub fn resolve_optional_string(flag: Option<&str>, xdg_key: &str) -> Option<String> {
131    if let Some(v) = flag {
132        if !v.is_empty() {
133            return Some(v.to_string());
134        }
135    }
136    config::get_setting(xdg_key)
137        .ok()
138        .flatten()
139        .filter(|s| !s.is_empty())
140}
141
142/// Parse usize from flag > XDG > default.
143pub fn resolve_usize(flag: Option<usize>, xdg_key: &str, default: usize) -> usize {
144    if let Some(v) = flag {
145        return v;
146    }
147    if let Ok(Some(v)) = config::get_setting(xdg_key) {
148        if let Ok(n) = v.parse::<usize>() {
149            return n;
150        }
151    }
152    default
153}
154
155/// Parse u64 from flag > XDG > default.
156pub fn resolve_u64(flag: Option<u64>, xdg_key: &str, default: u64) -> u64 {
157    if let Some(v) = flag {
158        return v;
159    }
160    if let Ok(Some(v)) = config::get_setting(xdg_key) {
161        if let Ok(n) = v.parse::<u64>() {
162            return n;
163        }
164    }
165    default
166}
167
168/// Parse f64 from flag > XDG > default.
169pub fn resolve_f64(flag: Option<f64>, xdg_key: &str, default: f64) -> f64 {
170    if let Some(v) = flag {
171        return v;
172    }
173    if let Ok(Some(v)) = config::get_setting(xdg_key) {
174        if let Ok(n) = v.parse::<f64>() {
175            return n;
176        }
177    }
178    default
179}
180
181/// Bool: CLI true wins; else XDG "1"/"true"/"yes"; else default.
182pub fn resolve_bool(flag_set: bool, xdg_key: &str, default: bool) -> bool {
183    if flag_set {
184        return true;
185    }
186    if let Ok(Some(v)) = config::get_setting(xdg_key) {
187        let t = v.trim().to_ascii_lowercase();
188        return matches!(t.as_str(), "1" | "true" | "yes" | "on");
189    }
190    default
191}
192
193/// Embedding dim: CLI override > XDG `embedding.dim` > None (caller uses DB/default).
194///
195/// The bound comes from [`crate::constants::EMBEDDING_DIM_RANGE`] so the CLI
196/// parser, this resolver and the warning text cannot disagree about what is
197/// accepted.
198pub fn embedding_dim_override() -> Option<u32> {
199    let rt = get();
200    if let Some(d) = rt.embedding_dim {
201        return Some(d);
202    }
203    if let Ok(Some(v)) = config::get_setting("embedding.dim") {
204        if let Ok(n) = v.parse::<u32>() {
205            if crate::constants::EMBEDDING_DIM_RANGE.contains(&(n as usize)) {
206                return Some(n);
207            }
208        }
209    }
210    None
211}
212
213/// Skip embedding on failure: runtime flag or XDG.
214pub fn skip_embedding_on_failure() -> bool {
215    let rt = get();
216    resolve_bool(
217        rt.skip_embedding_on_failure,
218        "llm.skip_embedding_on_failure",
219        false,
220    )
221}
222
223/// Host concurrency for LLM slots.
224pub fn llm_max_host_concurrency(default: usize) -> usize {
225    let rt = get();
226    resolve_usize(
227        rt.llm_max_host_concurrency,
228        "llm.max_host_concurrency",
229        default,
230    )
231}
232
233/// LLM slot wait secs.
234pub fn llm_slot_wait_secs(default: u64) -> u64 {
235    let rt = get();
236    if rt.llm_slot_no_wait {
237        return 0;
238    }
239    resolve_u64(rt.llm_slot_wait_secs, "llm.slot_wait_secs", default)
240}
241
242/// LLM slot no wait.
243pub fn llm_slot_no_wait() -> bool {
244    let rt = get();
245    resolve_bool(rt.llm_slot_no_wait, "llm.slot_no_wait", false)
246}
247
248/// Claude binary.
249pub fn claude_binary() -> Option<String> {
250    let rt = get();
251    resolve_optional_string(rt.claude_binary.as_deref(), "llm.claude_binary")
252}
253
254/// Codex binary.
255pub fn codex_binary() -> Option<String> {
256    let rt = get();
257    resolve_optional_string(rt.codex_binary.as_deref(), "llm.codex_binary")
258}
259
260/// Opencode binary.
261pub fn opencode_binary() -> Option<String> {
262    let rt = get();
263    resolve_optional_string(rt.opencode_binary.as_deref(), "llm.opencode_binary")
264}
265
266/// LLM model.
267pub fn llm_model() -> Option<String> {
268    let rt = get();
269    resolve_optional_string(rt.llm_model.as_deref(), "llm.model")
270}
271
272/// LLM fallback.
273pub fn llm_fallback(default: &str) -> String {
274    let rt = get();
275    resolve_string(rt.llm_fallback.as_deref(), "llm.fallback", default)
276}
277
278/// Log level.
279pub fn log_level(default: &str) -> String {
280    let rt = get();
281    resolve_string(rt.log_level.as_deref(), "log.level", default)
282}
283
284/// Log format.
285pub fn log_format(default: &str) -> String {
286    let rt = get();
287    resolve_string(rt.log_format.as_deref(), "log.format", default)
288}
289
290/// Max entities per memory.
291pub fn max_entities_per_memory(default: usize) -> usize {
292    resolve_usize(None, "limits.max_entities_per_memory", default)
293}
294
295/// Max relations per memory.
296pub fn max_relations_per_memory(default: usize) -> usize {
297    resolve_usize(None, "limits.max_relations_per_memory", default)
298}
299
300/// OpenRouter chat URL: XDG override or compile-time default.
301/// Canonical key: `network.openrouter.chat_url`; alias: `network.chat_url`.
302pub fn openrouter_chat_url(default: &str) -> String {
303    resolve_string_with_aliases(
304        None,
305        &["network.openrouter.chat_url", "network.chat_url"],
306        default,
307    )
308}
309
310/// OpenRouter embeddings URL: XDG override or compile-time default.
311/// Canonical key: `network.openrouter.embeddings_url`; alias: `network.embed_url`.
312pub fn openrouter_embeddings_url(default: &str) -> String {
313    resolve_string_with_aliases(
314        None,
315        &["network.openrouter.embeddings_url", "network.embed_url"],
316        default,
317    )
318}
319
320/// Probe timeout for fail-fast LLM backend readiness (ms).
321pub fn llm_probe_timeout_ms(default: u64) -> u64 {
322    resolve_u64(None, "llm.probe_timeout_ms", default)
323}
324
325/// Worker count for the global Rayon pool, from XDG `parallelism.rayon_threads`.
326///
327/// GAP-SG-92: the pool used to be sized by writing `RAYON_NUM_THREADS` into the
328/// process environment at startup, which made an env var the configuration
329/// channel and required an `unsafe` block. Reading the XDG key and handing the
330/// number to `ThreadPoolBuilder` keeps the policy inside the documented
331/// precedence and removes the mutation entirely.
332///
333/// A value of `0` is rejected in favour of `default`: Rayon treats zero as
334/// "detect the host CPU count", which silently discards the cap this knob
335/// exists to enforce.
336pub fn rayon_threads(default: usize) -> usize {
337    let n = resolve_usize(None, "parallelism.rayon_threads", default);
338    if n == 0 {
339        default
340    } else {
341        n
342    }
343}
344
345/// SQLITE_BUSY retry budget, from XDG `db.busy_retries`.
346pub fn db_busy_retries(default: u32) -> u32 {
347    resolve_u64(None, "db.busy_retries", u64::from(default)) as u32
348}
349
350/// Base backoff for the first SQLITE_BUSY retry, from XDG `db.busy_base_delay_ms`.
351pub fn db_busy_base_delay_ms(default: u64) -> u64 {
352    resolve_u64(None, "db.busy_base_delay_ms", default)
353}
354
355/// Per-statement query timeout, from XDG `db.query_timeout_ms`.
356pub fn db_query_timeout_ms(default: u64) -> u64 {
357    resolve_u64(None, "db.query_timeout_ms", default)
358}
359
360/// Embedding batch size, from XDG `embedding.batch_size`.
361///
362/// Clamped to at least 1 so a `0` in the config cannot produce an empty batch
363/// loop that never makes progress.
364pub fn embedding_batch_size(default: usize) -> usize {
365    resolve_usize(None, "embedding.batch_size", default).max(1)
366}
367
368/// flag > first non-empty XDG key in `keys` > default.
369fn resolve_string_with_aliases(flag: Option<&str>, keys: &[&str], default: &str) -> String {
370    if let Some(v) = flag {
371        if !v.is_empty() {
372            return v.to_string();
373        }
374    }
375    for key in keys {
376        if let Ok(Some(v)) = config::get_setting(key) {
377            if !v.is_empty() {
378                return v;
379            }
380        }
381    }
382    default.to_string()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn resolve_string_prefers_flag() {
391        assert_eq!(
392            resolve_string(Some("from-flag"), "nonexistent.key.xyz", "def"),
393            "from-flag"
394        );
395    }
396
397    #[test]
398    fn resolve_string_falls_to_default() {
399        assert_eq!(
400            resolve_string(None, "nonexistent.key.xyz.zzz", "def"),
401            "def"
402        );
403    }
404}