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    pub embedding_dim: Option<u32>,
14    pub claude_binary: Option<String>,
15    pub codex_binary: Option<String>,
16    pub opencode_binary: Option<String>, // path as string
17    pub llm_model: Option<String>,
18    pub llm_fallback: Option<String>,
19    pub skip_embedding_on_failure: bool,
20    pub llm_max_host_concurrency: Option<usize>,
21    pub llm_slot_wait_secs: Option<u64>,
22    pub llm_slot_no_wait: bool,
23    pub strict_env_clear: bool,
24    pub log_level: Option<String>,
25    pub log_format: Option<String>,
26    pub lang: Option<String>,
27    pub display_tz: Option<String>,
28    pub db_path: Option<String>,
29}
30
31static RUNTIME: OnceLock<RuntimeOverrides> = OnceLock::new();
32
33/// Install CLI-captured overrides. Idempotent first-wins (main bootstrap).
34pub fn init(overrides: RuntimeOverrides) {
35    let _ = RUNTIME.set(overrides);
36}
37
38/// Borrow installed overrides (empty defaults if init was skipped — tests).
39pub fn get() -> RuntimeOverrides {
40    RUNTIME.get().cloned().unwrap_or_default()
41}
42
43/// flag_opt > XDG setting > default.
44pub fn resolve_string(flag: Option<&str>, xdg_key: &str, default: &str) -> String {
45    if let Some(v) = flag {
46        if !v.is_empty() {
47            return v.to_string();
48        }
49    }
50    if let Ok(Some(v)) = config::get_setting(xdg_key) {
51        if !v.is_empty() {
52            return v;
53        }
54    }
55    default.to_string()
56}
57
58/// flag_opt > XDG setting > None.
59pub fn resolve_optional_string(flag: Option<&str>, xdg_key: &str) -> Option<String> {
60    if let Some(v) = flag {
61        if !v.is_empty() {
62            return Some(v.to_string());
63        }
64    }
65    config::get_setting(xdg_key).ok().flatten().filter(|s| !s.is_empty())
66}
67
68/// Parse usize from flag > XDG > default.
69pub fn resolve_usize(flag: Option<usize>, xdg_key: &str, default: usize) -> usize {
70    if let Some(v) = flag {
71        return v;
72    }
73    if let Ok(Some(v)) = config::get_setting(xdg_key) {
74        if let Ok(n) = v.parse::<usize>() {
75            return n;
76        }
77    }
78    default
79}
80
81/// Parse u64 from flag > XDG > default.
82pub fn resolve_u64(flag: Option<u64>, xdg_key: &str, default: u64) -> u64 {
83    if let Some(v) = flag {
84        return v;
85    }
86    if let Ok(Some(v)) = config::get_setting(xdg_key) {
87        if let Ok(n) = v.parse::<u64>() {
88            return n;
89        }
90    }
91    default
92}
93
94/// Parse f64 from flag > XDG > default.
95pub fn resolve_f64(flag: Option<f64>, xdg_key: &str, default: f64) -> f64 {
96    if let Some(v) = flag {
97        return v;
98    }
99    if let Ok(Some(v)) = config::get_setting(xdg_key) {
100        if let Ok(n) = v.parse::<f64>() {
101            return n;
102        }
103    }
104    default
105}
106
107/// Bool: CLI true wins; else XDG "1"/"true"/"yes"; else default.
108pub fn resolve_bool(flag_set: bool, xdg_key: &str, default: bool) -> bool {
109    if flag_set {
110        return true;
111    }
112    if let Ok(Some(v)) = config::get_setting(xdg_key) {
113        let t = v.trim().to_ascii_lowercase();
114        return matches!(t.as_str(), "1" | "true" | "yes" | "on");
115    }
116    default
117}
118
119/// Embedding dim: CLI override > XDG `embedding.dim` > None (caller uses DB/default).
120pub fn embedding_dim_override() -> Option<u32> {
121    let rt = get();
122    if let Some(d) = rt.embedding_dim {
123        return Some(d);
124    }
125    if let Ok(Some(v)) = config::get_setting("embedding.dim") {
126        if let Ok(n) = v.parse::<u32>() {
127            if (8..=4096).contains(&n) {
128                return Some(n);
129            }
130        }
131    }
132    None
133}
134
135/// Skip embedding on failure: runtime flag or XDG.
136pub fn skip_embedding_on_failure() -> bool {
137    let rt = get();
138    resolve_bool(
139        rt.skip_embedding_on_failure,
140        "llm.skip_embedding_on_failure",
141        false,
142    )
143}
144
145/// Host concurrency for LLM slots.
146pub fn llm_max_host_concurrency(default: usize) -> usize {
147    let rt = get();
148    resolve_usize(
149        rt.llm_max_host_concurrency,
150        "llm.max_host_concurrency",
151        default,
152    )
153}
154
155pub fn llm_slot_wait_secs(default: u64) -> u64 {
156    let rt = get();
157    if rt.llm_slot_no_wait {
158        return 0;
159    }
160    resolve_u64(rt.llm_slot_wait_secs, "llm.slot_wait_secs", default)
161}
162
163pub fn llm_slot_no_wait() -> bool {
164    let rt = get();
165    resolve_bool(rt.llm_slot_no_wait, "llm.slot_no_wait", false)
166}
167
168pub fn claude_binary() -> Option<String> {
169    let rt = get();
170    resolve_optional_string(rt.claude_binary.as_deref(), "llm.claude_binary")
171}
172
173pub fn codex_binary() -> Option<String> {
174    let rt = get();
175    resolve_optional_string(rt.codex_binary.as_deref(), "llm.codex_binary")
176}
177
178pub fn opencode_binary() -> Option<String> {
179    let rt = get();
180    resolve_optional_string(rt.opencode_binary.as_deref(), "llm.opencode_binary")
181}
182
183pub fn llm_model() -> Option<String> {
184    let rt = get();
185    resolve_optional_string(rt.llm_model.as_deref(), "llm.model")
186}
187
188pub fn llm_fallback(default: &str) -> String {
189    let rt = get();
190    resolve_string(rt.llm_fallback.as_deref(), "llm.fallback", default)
191}
192
193pub fn log_level(default: &str) -> String {
194    let rt = get();
195    resolve_string(rt.log_level.as_deref(), "log.level", default)
196}
197
198pub fn log_format(default: &str) -> String {
199    let rt = get();
200    resolve_string(rt.log_format.as_deref(), "log.format", default)
201}
202
203pub fn max_entities_per_memory(default: usize) -> usize {
204    resolve_usize(None, "limits.max_entities_per_memory", default)
205}
206
207pub fn max_relations_per_memory(default: usize) -> usize {
208    resolve_usize(None, "limits.max_relations_per_memory", default)
209}
210
211/// OpenRouter chat URL: XDG override or compile-time default.
212/// Canonical key: `network.openrouter.chat_url`; alias: `network.chat_url`.
213pub fn openrouter_chat_url(default: &str) -> String {
214    resolve_string_with_aliases(
215        None,
216        &["network.openrouter.chat_url", "network.chat_url"],
217        default,
218    )
219}
220
221/// OpenRouter embeddings URL: XDG override or compile-time default.
222/// Canonical key: `network.openrouter.embeddings_url`; alias: `network.embed_url`.
223pub fn openrouter_embeddings_url(default: &str) -> String {
224    resolve_string_with_aliases(
225        None,
226        &[
227            "network.openrouter.embeddings_url",
228            "network.embed_url",
229        ],
230        default,
231    )
232}
233
234/// Probe timeout for fail-fast LLM backend readiness (ms).
235pub fn llm_probe_timeout_ms(default: u64) -> u64 {
236    resolve_u64(None, "llm.probe_timeout_ms", default)
237}
238
239/// flag > first non-empty XDG key in `keys` > default.
240fn resolve_string_with_aliases(flag: Option<&str>, keys: &[&str], default: &str) -> String {
241    if let Some(v) = flag {
242        if !v.is_empty() {
243            return v.to_string();
244        }
245    }
246    for key in keys {
247        if let Ok(Some(v)) = config::get_setting(key) {
248            if !v.is_empty() {
249                return v;
250            }
251        }
252    }
253    default.to_string()
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn resolve_string_prefers_flag() {
262        assert_eq!(
263            resolve_string(Some("from-flag"), "nonexistent.key.xyz", "def"),
264            "from-flag"
265        );
266    }
267
268    #[test]
269    fn resolve_string_falls_to_default() {
270        assert_eq!(
271            resolve_string(None, "nonexistent.key.xyz.zzz", "def"),
272            "def"
273        );
274    }
275}