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}