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 /// LLM model.
16 pub llm_model: Option<String>,
17 /// LLM fallback.
18 pub llm_fallback: Option<String>,
19 /// Skip embedding on failure.
20 pub skip_embedding_on_failure: bool,
21 /// LLM max host concurrency.
22 pub llm_max_host_concurrency: Option<usize>,
23 /// LLM slot wait secs.
24 pub llm_slot_wait_secs: Option<u64>,
25 /// LLM slot no wait.
26 pub llm_slot_no_wait: bool,
27 /// Strict ENV clear.
28 /// CLI `--openrouter-timeout`, in seconds.
29 ///
30 /// Global since v1.2.3. It used to be declared only on `enrich`, so every
31 /// other embedding path — `remember`, `ingest`, `edit`, `restore`,
32 /// `split-body` and both read paths — was pinned to the compiled default
33 /// with no operator recourse, and a slow provider surfaced as exit 11.
34 /// `None` means the flag was omitted, which is what lets flag > XDG >
35 /// constant resolve instead of a clap default always winning.
36 pub openrouter_timeout: Option<u64>,
37 /// Log level.
38 pub log_level: Option<String>,
39 /// Log format.
40 pub log_format: Option<String>,
41 /// Lang.
42 pub lang: Option<String>,
43 /// Display TZ.
44 pub display_tz: Option<String>,
45 /// DB path.
46 pub db_path: Option<String>,
47}
48
49/// Directory overrides installed BEFORE anything reads `config.toml`.
50///
51/// These live in their own `OnceLock` because of an ordering hazard: `main`
52/// resolves the interface language during a pre-parse pass that runs before
53/// [`init`], and language resolution reads the XDG key `i18n.lang` — which
54/// means it reads `config.toml`, which means it needs `--config-dir` already.
55/// Folding these into [`RuntimeOverrides`] would force [`init`] to run first,
56/// and since both are first-wins `OnceLock`s the later call would be dropped.
57#[derive(Debug, Clone, Default)]
58pub struct PathOverrides {
59 /// CLI `--config-dir`: directory holding `config.toml`.
60 pub config_dir: Option<String>,
61 /// CLI `--cache-dir`: root for lock files, models and cache artifacts.
62 pub cache_dir: Option<String>,
63}
64
65static PATHS: OnceLock<PathOverrides> = OnceLock::new();
66
67/// Install directory overrides. Idempotent first-wins.
68///
69/// MUST be called before any code path that can read `config.toml`.
70pub fn init_paths(overrides: PathOverrides) {
71 let _ = PATHS.set(overrides);
72}
73
74fn paths() -> PathOverrides {
75 PATHS.get().cloned().unwrap_or_default()
76}
77
78static RUNTIME: OnceLock<RuntimeOverrides> = OnceLock::new();
79
80/// Install CLI-captured overrides. Idempotent first-wins (main bootstrap).
81pub fn init(overrides: RuntimeOverrides) {
82 let _ = RUNTIME.set(overrides);
83}
84
85/// Borrow installed overrides (empty defaults if init was skipped — tests).
86pub fn get() -> RuntimeOverrides {
87 RUNTIME.get().cloned().unwrap_or_default()
88}
89
90/// CLI `--config-dir` only.
91///
92/// Deliberately does NOT consult [`config::get_setting`]: the config file lives
93/// inside the directory this function resolves, so reading it here would be
94/// circular. The XDG default is applied by [`crate::paths::config_dir`].
95pub fn config_dir_override() -> Option<String> {
96 paths()
97 .config_dir
98 .map(|s| s.trim().to_string())
99 .filter(|s| !s.is_empty())
100}
101
102/// CLI `--cache-dir` > XDG `cache.dir` > `None` (caller applies the OS default).
103pub fn cache_dir_override() -> Option<String> {
104 if let Some(v) = paths().cache_dir {
105 if !v.trim().is_empty() {
106 return Some(v.trim().to_string());
107 }
108 }
109 config::get_setting("cache.dir")
110 .ok()
111 .flatten()
112 .map(|s| s.trim().to_string())
113 .filter(|s| !s.is_empty())
114}
115
116/// flag_opt > XDG setting > default.
117pub fn resolve_string(flag: Option<&str>, xdg_key: &str, default: &str) -> String {
118 if let Some(v) = flag {
119 if !v.is_empty() {
120 return v.to_string();
121 }
122 }
123 if let Ok(Some(v)) = config::get_setting(xdg_key) {
124 if !v.is_empty() {
125 return v;
126 }
127 }
128 default.to_string()
129}
130
131/// flag_opt > XDG setting > None.
132pub fn resolve_optional_string(flag: Option<&str>, xdg_key: &str) -> Option<String> {
133 if let Some(v) = flag {
134 if !v.is_empty() {
135 return Some(v.to_string());
136 }
137 }
138 config::get_setting(xdg_key)
139 .ok()
140 .flatten()
141 .filter(|s| !s.is_empty())
142}
143
144/// Parse usize from flag > XDG > default.
145pub fn resolve_usize(flag: Option<usize>, xdg_key: &str, default: usize) -> usize {
146 if let Some(v) = flag {
147 return v;
148 }
149 if let Ok(Some(v)) = config::get_setting(xdg_key) {
150 if let Ok(n) = v.parse::<usize>() {
151 return n;
152 }
153 }
154 default
155}
156
157/// Parse u64 from flag > XDG > default.
158pub fn resolve_u64(flag: Option<u64>, xdg_key: &str, default: u64) -> u64 {
159 if let Some(v) = flag {
160 return v;
161 }
162 if let Ok(Some(v)) = config::get_setting(xdg_key) {
163 if let Ok(n) = v.parse::<u64>() {
164 return n;
165 }
166 }
167 default
168}
169
170/// Parse f64 from flag > XDG > default.
171pub fn resolve_f64(flag: Option<f64>, xdg_key: &str, default: f64) -> f64 {
172 if let Some(v) = flag {
173 return v;
174 }
175 if let Ok(Some(v)) = config::get_setting(xdg_key) {
176 if let Ok(n) = v.parse::<f64>() {
177 return n;
178 }
179 }
180 default
181}
182
183/// Bool: CLI true wins; else XDG "1"/"true"/"yes"; else default.
184pub fn resolve_bool(flag_set: bool, xdg_key: &str, default: bool) -> bool {
185 if flag_set {
186 return true;
187 }
188 if let Ok(Some(v)) = config::get_setting(xdg_key) {
189 let t = v.trim().to_ascii_lowercase();
190 return matches!(t.as_str(), "1" | "true" | "yes" | "on");
191 }
192 default
193}
194
195/// Embedding dim: CLI override > XDG `embedding.dim` > None (caller uses DB/default).
196///
197/// The bound comes from [`crate::constants::EMBEDDING_DIM_RANGE`] so the CLI
198/// parser, this resolver and the warning text cannot disagree about what is
199/// accepted.
200pub fn embedding_dim_override() -> Option<u32> {
201 let rt = get();
202 if let Some(d) = rt.embedding_dim {
203 return Some(d);
204 }
205 if let Ok(Some(v)) = config::get_setting("embedding.dim") {
206 if let Ok(n) = v.parse::<u32>() {
207 if crate::constants::EMBEDDING_DIM_RANGE.contains(&(n as usize)) {
208 return Some(n);
209 }
210 }
211 }
212 None
213}
214
215/// Embedding model: CLI `--embedding-model` > XDG `embedding.model` > `None`.
216///
217/// `--embedding-model` documented this fallback from the day it shipped, but no
218/// resolver existed, so `config set embedding.model` was accepted, stored and
219/// then ignored — the invocation still died with exit 78 asking for the flag.
220///
221/// An empty stored value is treated as unset: a blank model name would reach
222/// the OpenRouter client and fail there with a far less legible error.
223pub fn embedding_model(cli: Option<&str>) -> Option<String> {
224 if let Some(m) = cli.map(str::trim).filter(|m| !m.is_empty()) {
225 return Some(m.to_string());
226 }
227 match config::get_setting("embedding.model") {
228 Ok(Some(v)) if !v.trim().is_empty() => Some(v.trim().to_string()),
229 _ => None,
230 }
231}
232
233/// Embedding backend: CLI `--embedding-backend` > XDG `embedding.backend` > `auto`.
234///
235/// `--embedding-backend` advertised "optional XDG `config set
236/// embedding.backend`" in its own help while the key was absent from the
237/// registry, so the documented command answered exit 1. This is the sibling
238/// defect of the one GAP-SG-192 fixed for `embedding.model`.
239///
240/// An unparseable stored value falls back to the compiled default rather than
241/// aborting: a typo in a machine-wide config file must not make every
242/// invocation on the host unusable, and the flag still overrides it.
243pub fn embedding_backend(
244 cli: Option<crate::backend_choice::EmbeddingBackendChoice>,
245) -> crate::backend_choice::EmbeddingBackendChoice {
246 use crate::backend_choice::EmbeddingBackendChoice as B;
247 if let Some(v) = cli {
248 return v;
249 }
250 match config::get_setting("embedding.backend") {
251 Ok(Some(v)) => match v.trim().to_ascii_lowercase().as_str() {
252 "openrouter" | "open-router" => B::Openrouter,
253 "auto" => B::Auto,
254 _ => B::Auto,
255 },
256 _ => B::Auto,
257 }
258}
259
260/// LLM backend for embedding: CLI `--llm-backend` > XDG `llm.backend` > `open-router`.
261///
262/// Same defect as [`embedding_backend`]: the flag promised the key and nothing
263/// registered or read it.
264pub fn llm_backend(
265 cli: Option<crate::backend_choice::LlmBackendChoice>,
266) -> crate::backend_choice::LlmBackendChoice {
267 use crate::backend_choice::LlmBackendChoice as B;
268 if let Some(v) = cli {
269 return v;
270 }
271 match config::get_setting("llm.backend") {
272 Ok(Some(v)) => match v.trim().to_ascii_lowercase().as_str() {
273 "none" => B::None,
274 "openrouter" | "open-router" => B::OpenRouter,
275 _ => B::OpenRouter,
276 },
277 _ => B::OpenRouter,
278 }
279}
280
281/// Skip embedding on failure: runtime flag or XDG.
282pub fn skip_embedding_on_failure() -> bool {
283 let rt = get();
284 resolve_bool(
285 rt.skip_embedding_on_failure,
286 "llm.skip_embedding_on_failure",
287 false,
288 )
289}
290
291/// Host concurrency for LLM slots.
292pub fn llm_max_host_concurrency(default: usize) -> usize {
293 let rt = get();
294 resolve_usize(
295 rt.llm_max_host_concurrency,
296 "llm.max_host_concurrency",
297 default,
298 )
299}
300
301/// LLM slot wait secs.
302pub fn llm_slot_wait_secs(default: u64) -> u64 {
303 let rt = get();
304 if rt.llm_slot_no_wait {
305 return 0;
306 }
307 resolve_u64(rt.llm_slot_wait_secs, "llm.slot_wait_secs", default)
308}
309
310/// LLM slot no wait.
311pub fn llm_slot_no_wait() -> bool {
312 let rt = get();
313 resolve_bool(rt.llm_slot_no_wait, "llm.slot_no_wait", false)
314}
315
316/// LLM model.
317pub fn llm_model() -> Option<String> {
318 let rt = get();
319 resolve_optional_string(rt.llm_model.as_deref(), "llm.model")
320}
321
322/// LLM fallback.
323pub fn llm_fallback(default: &str) -> String {
324 let rt = get();
325 resolve_string(rt.llm_fallback.as_deref(), "llm.fallback", default)
326}
327
328/// Effective OpenRouter CHAT budget in seconds: `--openrouter-timeout`, then
329/// XDG `llm.openrouter_timeout_secs`, then `default`.
330///
331/// Separate from `embedding.timeout_secs`, which budgets the EMBEDDING client.
332/// The same flag feeds both because one invocation talks to one provider, but
333/// the two XDG keys stay distinct: a host may need a long chat budget for dense
334/// bodies and a short embed budget to keep reads responsive.
335pub fn openrouter_chat_timeout_secs(default: u64) -> u64 {
336 let rt = get();
337 resolve_u64(
338 rt.openrouter_timeout,
339 "llm.openrouter_timeout_secs",
340 default,
341 )
342}
343
344/// CLI `--openrouter-timeout` as passed, with no XDG or constant layered on.
345///
346/// The embedding client needs the RAW override because it resolves against its
347/// own key (`embedding.timeout_secs`); handing it an already-resolved value
348/// would make the flag indistinguishable from a default and silently promote
349/// the constant above XDG.
350pub fn openrouter_timeout_override() -> Option<u64> {
351 get().openrouter_timeout
352}
353
354/// Log level.
355pub fn log_level(default: &str) -> String {
356 let rt = get();
357 resolve_string(rt.log_level.as_deref(), "log.level", default)
358}
359
360/// Log format.
361pub fn log_format(default: &str) -> String {
362 let rt = get();
363 resolve_string(rt.log_format.as_deref(), "log.format", default)
364}
365
366/// Max entities per memory.
367pub fn max_entities_per_memory(default: usize) -> usize {
368 resolve_usize(None, "limits.max_entities_per_memory", default)
369}
370
371/// Max relations per memory.
372pub fn max_relations_per_memory(default: usize) -> usize {
373 resolve_usize(None, "limits.max_relations_per_memory", default)
374}
375
376/// OpenRouter chat URL: XDG override or compile-time default.
377/// Canonical key: `network.openrouter.chat_url`; alias: `network.chat_url`.
378pub fn openrouter_chat_url(default: &str) -> String {
379 resolve_string_with_aliases(
380 None,
381 &["network.openrouter.chat_url", "network.chat_url"],
382 default,
383 )
384}
385
386/// OpenRouter embeddings URL: XDG override or compile-time default.
387/// Canonical key: `network.openrouter.embeddings_url`; alias: `network.embed_url`.
388pub fn openrouter_embeddings_url(default: &str) -> String {
389 resolve_string_with_aliases(
390 None,
391 &["network.openrouter.embeddings_url", "network.embed_url"],
392 default,
393 )
394}
395
396/// Probe timeout for fail-fast LLM backend readiness (ms).
397pub fn llm_probe_timeout_ms(default: u64) -> u64 {
398 resolve_u64(None, "llm.probe_timeout_ms", default)
399}
400
401/// Worker count for the global Rayon pool, from XDG `parallelism.rayon_threads`.
402///
403/// GAP-SG-92: the pool used to be sized by writing `RAYON_NUM_THREADS` into the
404/// process environment at startup, which made an env var the configuration
405/// channel and required an `unsafe` block. Reading the XDG key and handing the
406/// number to `ThreadPoolBuilder` keeps the policy inside the documented
407/// precedence and removes the mutation entirely.
408///
409/// A value of `0` is rejected in favour of `default`: Rayon treats zero as
410/// "detect the host CPU count", which silently discards the cap this knob
411/// exists to enforce.
412pub fn rayon_threads(default: usize) -> usize {
413 let n = resolve_usize(None, "parallelism.rayon_threads", default);
414 if n == 0 {
415 default
416 } else {
417 n
418 }
419}
420
421/// Worker count for the shared embedding Tokio runtime, from XDG
422/// `parallelism.embed_runtime_threads` (GAP-SG-141 B2).
423///
424/// The runtime used to be built with a hard-coded two workers while the enrich
425/// drain fanned out up to sixteen blocking callers onto it. `default` is the
426/// host-derived size computed by the caller; this function only applies the
427/// operator override.
428///
429/// A value of `0` is rejected in favour of `default`: `worker_threads(0)`
430/// panics in Tokio, so a typo in the config file would abort the process.
431pub fn embed_runtime_worker_threads(default: usize) -> usize {
432 let n = resolve_usize(None, "parallelism.embed_runtime_threads", default);
433 if n == 0 {
434 default
435 } else {
436 n
437 }
438}
439
440/// GAP-SG-142: cap on emitted result elements, from `--max-items` or XDG
441/// `agent_surface.max_items`.
442///
443/// `0` keeps every element, which is what makes the shaping surface opt-in:
444/// an operator who sets nothing gets the historical envelope unchanged.
445pub fn agent_surface_max_items(flag: Option<usize>) -> usize {
446 resolve_usize(
447 flag,
448 "agent_surface.max_items",
449 crate::constants::DEFAULT_AGENT_SURFACE_MAX_ITEMS,
450 )
451}
452
453/// GAP-SG-142: cap on string length in characters, from `--truncate-content`
454/// or XDG `agent_surface.truncate_content`. `0` disables truncation.
455pub fn agent_surface_truncate_content(flag: Option<usize>) -> usize {
456 resolve_usize(
457 flag,
458 "agent_surface.truncate_content",
459 crate::constants::DEFAULT_AGENT_SURFACE_TRUNCATE_CONTENT,
460 )
461}
462
463/// GAP-SG-142: cap on the serialized envelope in bytes, from
464/// `--max-output-bytes` or XDG `agent_surface.max_output_bytes`.
465/// `0` disables the ceiling.
466pub fn agent_surface_max_output_bytes(flag: Option<usize>) -> usize {
467 resolve_usize(
468 flag,
469 "agent_surface.max_output_bytes",
470 crate::constants::DEFAULT_AGENT_SURFACE_MAX_OUTPUT_BYTES,
471 )
472}
473
474/// SQLITE_BUSY retry budget, from XDG `db.busy_retries`.
475pub fn db_busy_retries(default: u32) -> u32 {
476 resolve_u64(None, "db.busy_retries", u64::from(default)) as u32
477}
478
479/// Base backoff for the first SQLITE_BUSY retry, from XDG `db.busy_base_delay_ms`.
480pub fn db_busy_base_delay_ms(default: u64) -> u64 {
481 resolve_u64(None, "db.busy_base_delay_ms", default)
482}
483
484/// Per-statement query timeout, from XDG `db.query_timeout_ms`.
485pub fn db_query_timeout_ms(default: u64) -> u64 {
486 resolve_u64(None, "db.query_timeout_ms", default)
487}
488
489/// Embedding batch size, from XDG `embedding.batch_size`.
490///
491/// Clamped to at least 1 so a `0` in the config cannot produce an empty batch
492/// loop that never makes progress.
493pub fn embedding_batch_size(default: usize) -> usize {
494 resolve_usize(None, "embedding.batch_size", default).max(1)
495}
496
497/// GAP-SG-141 (B1): how many `ReEmbed` queue rows one claim takes, from XDG
498/// `enrich.reembed_claim_batch`.
499///
500/// There is no CLI flag: the width is a host-tuning concern, not a per-command
501/// decision, and the default already matches the 32-item chunk the OpenRouter
502/// embedding path uses internally. Values outside
503/// [`crate::constants::REEMBED_CLAIM_BATCH_RANGE`] are clamped rather than
504/// rejected so a stale config can never stall a drain.
505pub fn reembed_claim_batch() -> usize {
506 let range = crate::constants::REEMBED_CLAIM_BATCH_RANGE;
507 resolve_usize(
508 None,
509 "enrich.reembed_claim_batch",
510 crate::constants::DEFAULT_REEMBED_CLAIM_BATCH,
511 )
512 .clamp(*range.start(), *range.end())
513}
514
515/// GAP-SG-185: keyset page size for enrich scan collectors.
516///
517/// Precedence: CLI `--scan-page-size` > XDG `enrich.scan_page_size` >
518/// [`crate::constants::DEFAULT_ENRICH_SCAN_PAGE_SIZE`]. Values outside
519/// [`crate::constants::ENRICH_SCAN_PAGE_SIZE_RANGE`] are clamped.
520pub fn enrich_scan_page_size(cli: Option<usize>) -> usize {
521 let range = crate::constants::ENRICH_SCAN_PAGE_SIZE_RANGE;
522 resolve_usize(
523 cli,
524 "enrich.scan_page_size",
525 crate::constants::DEFAULT_ENRICH_SCAN_PAGE_SIZE,
526 )
527 .clamp(*range.start(), *range.end())
528}
529
530/// Deadline a drain keeps absorbing provider rate limits, from XDG
531/// `enrich.rate_limit_deadline_secs`.
532///
533/// There is no CLI flag: the tolerable quota window is a property of the host's
534/// provider account, not of one invocation. `0` is rejected in favour of the
535/// default because a zero deadline would abort on the first rate limit, which
536/// is the opposite of what this budget exists for.
537pub fn rate_limit_deadline_secs() -> std::time::Duration {
538 let secs = resolve_u64(
539 None,
540 "enrich.rate_limit_deadline_secs",
541 crate::constants::DEFAULT_RATE_LIMIT_DEADLINE_SECS,
542 );
543 let secs = if secs == 0 {
544 crate::constants::DEFAULT_RATE_LIMIT_DEADLINE_SECS
545 } else {
546 secs
547 };
548 std::time::Duration::from_secs(secs)
549}
550
551/// Cooldown of a tripped per-worker circuit breaker, from XDG
552/// `enrich.circuit_breaker_reset_secs`.
553///
554/// `0` is rejected in favour of the default: a zero cooldown lets the breaker
555/// re-close immediately, which disables the protection instead of tuning it.
556pub fn enrich_circuit_breaker_reset_secs() -> std::time::Duration {
557 let secs = resolve_u64(
558 None,
559 "enrich.circuit_breaker_reset_secs",
560 crate::constants::DEFAULT_ENRICH_CIRCUIT_BREAKER_RESET_SECS,
561 );
562 let secs = if secs == 0 {
563 crate::constants::DEFAULT_ENRICH_CIRCUIT_BREAKER_RESET_SECS
564 } else {
565 secs
566 };
567 std::time::Duration::from_secs(secs)
568}
569
570/// Deadline for reading a body from stdin, from XDG `cli.stdin_timeout_secs`.
571///
572/// `0` is rejected in favour of the default: a zero deadline would make every
573/// stdin read fail instantly, which `--no-input` already expresses deliberately.
574pub fn stdin_timeout_secs() -> u64 {
575 let secs = resolve_u64(
576 None,
577 "cli.stdin_timeout_secs",
578 crate::constants::DEFAULT_STDIN_READ_TIMEOUT_SECS,
579 );
580 if secs == 0 {
581 crate::constants::DEFAULT_STDIN_READ_TIMEOUT_SECS
582 } else {
583 secs
584 }
585}
586
587/// Whether this invocation refuses to read stdin: CLI `--no-input` > XDG
588/// `cli.no_input` > `false`.
589///
590/// The flag is one-way by design — passing it turns the refusal on, and a host
591/// that opted in through XDG turns it off by unsetting the key rather than by a
592/// `--no-input=false`, which would read as "input is allowed here" while the
593/// surrounding automation assumes otherwise.
594pub fn no_input(flag: bool) -> bool {
595 resolve_bool(flag, "cli.no_input", false)
596}
597
598/// flag > first non-empty XDG key in `keys` > default.
599fn resolve_string_with_aliases(flag: Option<&str>, keys: &[&str], default: &str) -> String {
600 if let Some(v) = flag {
601 if !v.is_empty() {
602 return v.to_string();
603 }
604 }
605 for key in keys {
606 if let Ok(Some(v)) = config::get_setting(key) {
607 if !v.is_empty() {
608 return v;
609 }
610 }
611 }
612 default.to_string()
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn resolve_string_prefers_flag() {
621 assert_eq!(
622 resolve_string(Some("from-flag"), "nonexistent.key.xyz", "def"),
623 "from-flag"
624 );
625 }
626
627 #[test]
628 fn resolve_string_falls_to_default() {
629 assert_eq!(
630 resolve_string(None, "nonexistent.key.xyz.zzz", "def"),
631 "def"
632 );
633 }
634}