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).ok().flatten().filter(|s| !s.is_empty())
137}
138
139/// Parse usize from flag > XDG > default.
140pub fn resolve_usize(flag: Option<usize>, xdg_key: &str, default: usize) -> usize {
141    if let Some(v) = flag {
142        return v;
143    }
144    if let Ok(Some(v)) = config::get_setting(xdg_key) {
145        if let Ok(n) = v.parse::<usize>() {
146            return n;
147        }
148    }
149    default
150}
151
152/// Parse u64 from flag > XDG > default.
153pub fn resolve_u64(flag: Option<u64>, xdg_key: &str, default: u64) -> u64 {
154    if let Some(v) = flag {
155        return v;
156    }
157    if let Ok(Some(v)) = config::get_setting(xdg_key) {
158        if let Ok(n) = v.parse::<u64>() {
159            return n;
160        }
161    }
162    default
163}
164
165/// Parse f64 from flag > XDG > default.
166pub fn resolve_f64(flag: Option<f64>, xdg_key: &str, default: f64) -> f64 {
167    if let Some(v) = flag {
168        return v;
169    }
170    if let Ok(Some(v)) = config::get_setting(xdg_key) {
171        if let Ok(n) = v.parse::<f64>() {
172            return n;
173        }
174    }
175    default
176}
177
178/// Bool: CLI true wins; else XDG "1"/"true"/"yes"; else default.
179pub fn resolve_bool(flag_set: bool, xdg_key: &str, default: bool) -> bool {
180    if flag_set {
181        return true;
182    }
183    if let Ok(Some(v)) = config::get_setting(xdg_key) {
184        let t = v.trim().to_ascii_lowercase();
185        return matches!(t.as_str(), "1" | "true" | "yes" | "on");
186    }
187    default
188}
189
190/// Embedding dim: CLI override > XDG `embedding.dim` > None (caller uses DB/default).
191///
192/// The bound comes from [`crate::constants::EMBEDDING_DIM_RANGE`] so the CLI
193/// parser, this resolver and the warning text cannot disagree about what is
194/// accepted.
195pub fn embedding_dim_override() -> Option<u32> {
196    let rt = get();
197    if let Some(d) = rt.embedding_dim {
198        return Some(d);
199    }
200    if let Ok(Some(v)) = config::get_setting("embedding.dim") {
201        if let Ok(n) = v.parse::<u32>() {
202            if crate::constants::EMBEDDING_DIM_RANGE.contains(&(n as usize)) {
203                return Some(n);
204            }
205        }
206    }
207    None
208}
209
210/// Skip embedding on failure: runtime flag or XDG.
211pub fn skip_embedding_on_failure() -> bool {
212    let rt = get();
213    resolve_bool(
214        rt.skip_embedding_on_failure,
215        "llm.skip_embedding_on_failure",
216        false,
217    )
218}
219
220/// Host concurrency for LLM slots.
221pub fn llm_max_host_concurrency(default: usize) -> usize {
222    let rt = get();
223    resolve_usize(
224        rt.llm_max_host_concurrency,
225        "llm.max_host_concurrency",
226        default,
227    )
228}
229
230/// LLM slot wait secs.
231pub fn llm_slot_wait_secs(default: u64) -> u64 {
232    let rt = get();
233    if rt.llm_slot_no_wait {
234        return 0;
235    }
236    resolve_u64(rt.llm_slot_wait_secs, "llm.slot_wait_secs", default)
237}
238
239/// LLM slot no wait.
240pub fn llm_slot_no_wait() -> bool {
241    let rt = get();
242    resolve_bool(rt.llm_slot_no_wait, "llm.slot_no_wait", false)
243}
244
245/// Claude binary.
246pub fn claude_binary() -> Option<String> {
247    let rt = get();
248    resolve_optional_string(rt.claude_binary.as_deref(), "llm.claude_binary")
249}
250
251/// Codex binary.
252pub fn codex_binary() -> Option<String> {
253    let rt = get();
254    resolve_optional_string(rt.codex_binary.as_deref(), "llm.codex_binary")
255}
256
257/// Opencode binary.
258pub fn opencode_binary() -> Option<String> {
259    let rt = get();
260    resolve_optional_string(rt.opencode_binary.as_deref(), "llm.opencode_binary")
261}
262
263/// LLM model.
264pub fn llm_model() -> Option<String> {
265    let rt = get();
266    resolve_optional_string(rt.llm_model.as_deref(), "llm.model")
267}
268
269/// LLM fallback.
270pub fn llm_fallback(default: &str) -> String {
271    let rt = get();
272    resolve_string(rt.llm_fallback.as_deref(), "llm.fallback", default)
273}
274
275/// Log level.
276pub fn log_level(default: &str) -> String {
277    let rt = get();
278    resolve_string(rt.log_level.as_deref(), "log.level", default)
279}
280
281/// Log format.
282pub fn log_format(default: &str) -> String {
283    let rt = get();
284    resolve_string(rt.log_format.as_deref(), "log.format", default)
285}
286
287/// Max entities per memory.
288pub fn max_entities_per_memory(default: usize) -> usize {
289    resolve_usize(None, "limits.max_entities_per_memory", default)
290}
291
292/// Max relations per memory.
293pub fn max_relations_per_memory(default: usize) -> usize {
294    resolve_usize(None, "limits.max_relations_per_memory", default)
295}
296
297/// OpenRouter chat URL: XDG override or compile-time default.
298/// Canonical key: `network.openrouter.chat_url`; alias: `network.chat_url`.
299pub fn openrouter_chat_url(default: &str) -> String {
300    resolve_string_with_aliases(
301        None,
302        &["network.openrouter.chat_url", "network.chat_url"],
303        default,
304    )
305}
306
307/// OpenRouter embeddings URL: XDG override or compile-time default.
308/// Canonical key: `network.openrouter.embeddings_url`; alias: `network.embed_url`.
309pub fn openrouter_embeddings_url(default: &str) -> String {
310    resolve_string_with_aliases(
311        None,
312        &[
313            "network.openrouter.embeddings_url",
314            "network.embed_url",
315        ],
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 { default } else { n }
339}
340
341/// SQLITE_BUSY retry budget, from XDG `db.busy_retries`.
342pub fn db_busy_retries(default: u32) -> u32 {
343    resolve_u64(None, "db.busy_retries", u64::from(default)) as u32
344}
345
346/// Base backoff for the first SQLITE_BUSY retry, from XDG `db.busy_base_delay_ms`.
347pub fn db_busy_base_delay_ms(default: u64) -> u64 {
348    resolve_u64(None, "db.busy_base_delay_ms", default)
349}
350
351/// Per-statement query timeout, from XDG `db.query_timeout_ms`.
352pub fn db_query_timeout_ms(default: u64) -> u64 {
353    resolve_u64(None, "db.query_timeout_ms", default)
354}
355
356/// Embedding batch size, from XDG `embedding.batch_size`.
357///
358/// Clamped to at least 1 so a `0` in the config cannot produce an empty batch
359/// loop that never makes progress.
360pub fn embedding_batch_size(default: usize) -> usize {
361    resolve_usize(None, "embedding.batch_size", default).max(1)
362}
363
364/// flag > first non-empty XDG key in `keys` > default.
365fn resolve_string_with_aliases(flag: Option<&str>, keys: &[&str], default: &str) -> String {
366    if let Some(v) = flag {
367        if !v.is_empty() {
368            return v.to_string();
369        }
370    }
371    for key in keys {
372        if let Ok(Some(v)) = config::get_setting(key) {
373            if !v.is_empty() {
374                return v;
375            }
376        }
377    }
378    default.to_string()
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn resolve_string_prefers_flag() {
387        assert_eq!(
388            resolve_string(Some("from-flag"), "nonexistent.key.xyz", "def"),
389            "from-flag"
390        );
391    }
392
393    #[test]
394    fn resolve_string_falls_to_default() {
395        assert_eq!(
396            resolve_string(None, "nonexistent.key.xyz.zzz", "def"),
397            "def"
398        );
399    }
400}