Skip to main content

sqlite_graphrag/config/
mod.rs

1//! XDG-based API key management for OpenRouter and other providers.
2//!
3//! Stores keys in `$XDG_CONFIG_HOME/sqlite-graphrag/config.toml` with
4//! atomic write, symlink-attack defense and Unix permission hardening.
5
6use secrecy::SecretBox;
7use serde::{Deserialize, Serialize};
8
9mod api_keys;
10mod permissions;
11mod registry;
12mod settings;
13mod store;
14
15// GAP-SG-146: `config.rs` was the largest non-test file in the crate. It was
16// split by responsibility, and every public item is re-exported here so no
17// caller outside this module had to change. `SETTING_KEYS` in particular is
18// `pub` and consumed from outside the crate.
19pub use api_keys::{compute_fingerprint, mask_key, resolve_api_key};
20pub use registry::{is_known_setting, nearest_setting_key, setting_key_names, SETTING_KEYS};
21pub use settings::{get_setting, list_settings, set_setting, unset_setting};
22pub use store::{config_file_path, load_config, save_config};
23
24/// App config.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AppConfig {
27    /// Configuration schema version.
28    pub schema_version: u32,
29    /// Keys.
30    #[serde(default)]
31    pub keys: Vec<ApiKeyEntry>,
32    /// Operational settings persisted via `config set/get` (G-T-XDG-01).
33    /// Stringly-typed map keeps the schema open without migrations for every
34    /// new key. Known keys are documented in `config set --help`.
35    #[serde(default)]
36    pub settings: std::collections::BTreeMap<String, String>,
37}
38
39/// API key entry.
40#[derive(Clone, Serialize, Deserialize)]
41pub struct ApiKeyEntry {
42    /// Provider name.
43    pub provider: String,
44    /// Value.
45    pub value: String,
46    /// Added at.
47    pub added_at: String,
48    /// Fingerprint.
49    pub fingerprint: String,
50}
51
52impl std::fmt::Debug for ApiKeyEntry {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("ApiKeyEntry")
55            .field("provider", &self.provider)
56            .field("value", &mask_key(&self.value))
57            .field("added_at", &self.added_at)
58            .field("fingerprint", &self.fingerprint)
59            .finish()
60    }
61}
62
63impl Default for AppConfig {
64    fn default() -> Self {
65        Self {
66            schema_version: 1,
67            keys: vec![],
68            settings: std::collections::BTreeMap::new(),
69        }
70    }
71}
72
73/// One entry of the canonical setting registry.
74///
75/// Carrying the default alongside the key is what lets `config doctor` derive
76/// its whole listing from [`SETTING_KEYS`] instead of repeating a hand-written
77/// table. `GAP-SG-85` was exactly that divergence: a 14-entry manual list next
78/// to a 44-key registry, missing the one key that redirects the database.
79pub struct SettingKey {
80    /// Dotted key accepted by `config set`.
81    pub key: &'static str,
82    /// Literal default applied when neither a CLI flag nor the XDG config
83    /// supplies a value.
84    ///
85    /// `None` marks a default that cannot be a static string because it is
86    /// derived from the host at runtime — an XDG directory, the CPU count, or
87    /// a probe. `config doctor` reports those as `derived` rather than
88    /// inventing a number that would not match what the process actually uses.
89    pub default: Option<&'static str>,
90    /// Domain the stored value must belong to, enforced by [`set_setting`].
91    ///
92    /// `GAP-SG-201`: the registry validated the KEY and never the VALUE, so
93    /// `config set embedding.dim nao-numero` reported success and the defect
94    /// surfaced on the next invocation, far from its cause. `display.tz 0` was
95    /// the worst case — it bricked the whole binary (`GAP-SG-200`).
96    pub kind: ValueKind,
97}
98
99/// Domain a setting value must belong to.
100///
101/// Deliberately coarse. The point is to reject what can never work, not to
102/// re-derive every consumer's parsing: a `u64` reader still clamps its own
103/// range, and this only guarantees it receives digits at all.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ValueKind {
106    /// A boolean in any spelling the readers accept.
107    ///
108    /// Deliberately wider than `true|false`. The readers are not uniform —
109    /// `tracing_init.rs` takes `1|true|yes|on`, `lib.rs` the same four,
110    /// `retry.rs` only three — and the `--low-memory` help has always
111    /// advertised `config set ingest.low_memory 1`. Validating against the
112    /// narrowest spelling would reject a value the product documents and every
113    /// reader honours, which is a regression dressed as a check.
114    ///
115    /// The job here is to reject what NO reader accepts (`talvez`, `maybe`),
116    /// not to impose a house style on values that already work.
117    Bool,
118    /// A non-negative integer.
119    Unsigned,
120    /// A finite decimal number.
121    Float,
122    /// An IANA timezone name, e.g. `America/Sao_Paulo`.
123    Tz,
124    /// An absolute `http`/`https` URL.
125    Url,
126    /// A filesystem path. Only emptiness is rejected: existence is the
127    /// caller's business, and a path that does not exist yet is legitimate.
128    Path,
129    /// Free text with no checkable domain.
130    Text,
131    /// A `tracing` filter directive, e.g. `warn` or `sqlite_graphrag=debug`.
132    ///
133    /// Not a closed set: the grammar accepts per-target directives, so listing
134    /// the five level names would reject legitimate values. Validated by
135    /// parsing with the same type `tracing_init` builds, because
136    /// `EnvFilter::new` DISCARDS an unparseable directive instead of failing —
137    /// which is how `log.level NIVEL_X` used to silence logging outright while
138    /// `config set` reported success.
139    LogDirective,
140    /// One of a closed set of spellings.
141    OneOf(&'static [&'static str]),
142}
143
144impl ValueKind {
145    /// Returns a LOCALIZED description of the accepted domain, for the error
146    /// message.
147    ///
148    /// Returns `None` when every string is acceptable, which is the signal to
149    /// skip validation entirely rather than to accept with an empty reason.
150    ///
151    /// Localized rather than hard-coded English because the message that
152    /// carries it is translated, and half-translating a sentence is worse than
153    /// not translating it: the operator reads Portuguese prose that ends in an
154    /// English clause.
155    ///
156    /// A closed set is NOT translated — those are literal spellings the
157    /// operator must type, so `true|false` is the same string in every locale.
158    pub fn expectation(&self) -> Option<String> {
159        match self {
160            ValueKind::Text => None,
161            ValueKind::OneOf(options) => Some(options.join("|")),
162            ValueKind::Bool => Some("true|false (also 1|0, yes|no, on|off)".to_string()),
163            other => Some(crate::i18n::validation::config_value_expectation(*other)),
164        }
165    }
166
167    /// `true` when `value` belongs to this domain.
168    pub fn accepts(&self, value: &str) -> bool {
169        let trimmed = value.trim();
170        match self {
171            ValueKind::Text => true,
172            ValueKind::Bool => matches!(
173                trimmed.to_ascii_lowercase().as_str(),
174                "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off"
175            ),
176            ValueKind::Unsigned => !trimmed.is_empty() && trimmed.parse::<u64>().is_ok(),
177            ValueKind::Float => trimmed
178                .parse::<f64>()
179                .is_ok_and(|parsed| parsed.is_finite()),
180            ValueKind::Tz => trimmed.parse::<chrono_tz::Tz>().is_ok(),
181            // Scheme-only check on purpose. A full parse would drag a URL crate
182            // into a path that just needs to reject `not-a-url` before the HTTP
183            // client fails on it much later, with a much worse message.
184            ValueKind::Url => {
185                (trimmed.starts_with("http://") || trimmed.starts_with("https://"))
186                    && trimmed.len() > "https://".len()
187            }
188            ValueKind::Path => !trimmed.is_empty(),
189            ValueKind::LogDirective => {
190                !trimmed.is_empty() && tracing_subscriber::EnvFilter::try_new(trimmed).is_ok()
191            }
192            ValueKind::OneOf(options) => options.contains(&trimmed),
193        }
194    }
195}
196
197/// Resolved key.
198pub struct ResolvedKey {
199    /// Value.
200    pub value: SecretBox<String>,
201    /// Source side of the relationship.
202    pub source: &'static str,
203}