Skip to main content

sqlite_graphrag/
config.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 crate::errors::AppError;
7use crate::i18n::validation;
8use secrecy::SecretBox;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12/// App config.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AppConfig {
15    /// Configuration schema version.
16    pub schema_version: u32,
17    /// Keys.
18    #[serde(default)]
19    pub keys: Vec<ApiKeyEntry>,
20    /// Operational settings persisted via `config set/get` (G-T-XDG-01).
21    /// Stringly-typed map keeps the schema open without migrations for every
22    /// new key. Known keys are documented in `config set --help`.
23    #[serde(default)]
24    pub settings: std::collections::BTreeMap<String, String>,
25}
26
27/// API key entry.
28#[derive(Clone, Serialize, Deserialize)]
29pub struct ApiKeyEntry {
30    /// Provider name.
31    pub provider: String,
32    /// Value.
33    pub value: String,
34    /// Added at.
35    pub added_at: String,
36    /// Fingerprint.
37    pub fingerprint: String,
38}
39
40impl std::fmt::Debug for ApiKeyEntry {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("ApiKeyEntry")
43            .field("provider", &self.provider)
44            .field("value", &mask_key(&self.value))
45            .field("added_at", &self.added_at)
46            .field("fingerprint", &self.fingerprint)
47            .finish()
48    }
49}
50
51impl Default for AppConfig {
52    fn default() -> Self {
53        Self {
54            schema_version: 1,
55            keys: vec![],
56            settings: std::collections::BTreeMap::new(),
57        }
58    }
59}
60
61/// One entry of the canonical setting registry.
62///
63/// Carrying the default alongside the key is what lets `config doctor` derive
64/// its whole listing from [`SETTING_KEYS`] instead of repeating a hand-written
65/// table. `GAP-SG-85` was exactly that divergence: a 14-entry manual list next
66/// to a 44-key registry, missing the one key that redirects the database.
67pub struct SettingKey {
68    /// Dotted key accepted by `config set`.
69    pub key: &'static str,
70    /// Literal default applied when neither a CLI flag nor the XDG config
71    /// supplies a value.
72    ///
73    /// `None` marks a default that cannot be a static string because it is
74    /// derived from the host at runtime — an XDG directory, the CPU count, or
75    /// a probe. `config doctor` reports those as `derived` rather than
76    /// inventing a number that would not match what the process actually uses.
77    pub default: Option<&'static str>,
78}
79
80/// Canonical registry of operational setting keys accepted by `config set`.
81///
82/// Single source of truth shared by [`set_setting`] validation and the
83/// `config doctor` knob listing. Keeping exactly one list prevents the
84/// divergence class recorded as `GAP-SG-79`, where help text advertised
85/// `db.default_path` while [`crate::paths::AppPaths::resolve`] has always
86/// read `db.path`.
87///
88/// Every entry MUST have a matching [`get_setting`] reader somewhere in the
89/// crate. Adding a key here without a reader recreates the silent no-op this
90/// registry exists to prevent; `GAP-SG-90` adds the test that enforces it.
91///
92/// Literal defaults that mirror a constant are asserted against that constant
93/// in this module's tests, so the two cannot drift apart in silence.
94///
95/// Kept sorted so the emitted diagnostics are stable across runs.
96pub const SETTING_KEYS: &[SettingKey] = &[
97    SettingKey {
98        key: "cache.dir",
99        default: None,
100    },
101    SettingKey {
102        key: "cli.max_instances",
103        default: None,
104    },
105    SettingKey {
106        key: "db.busy_base_delay_ms",
107        default: Some("300"),
108    },
109    SettingKey {
110        key: "db.busy_retries",
111        default: Some("5"),
112    },
113    SettingKey {
114        key: "db.path",
115        default: None,
116    },
117    SettingKey {
118        key: "db.query_timeout_ms",
119        default: Some("5000"),
120    },
121    SettingKey {
122        key: "display.tz",
123        default: Some("UTC"),
124    },
125    SettingKey {
126        key: "embedding.batch_size",
127        default: Some("32"),
128    },
129    SettingKey {
130        key: "embedding.claude_model",
131        default: None,
132    },
133    SettingKey {
134        key: "embedding.codex_model",
135        default: None,
136    },
137    SettingKey {
138        key: "embedding.dim",
139        default: Some("1024"),
140    },
141    SettingKey {
142        key: "embedding.opencode_model",
143        default: None,
144    },
145    SettingKey {
146        key: "enrich.entity_connect.default_limit",
147        default: Some("100"),
148    },
149    SettingKey {
150        key: "enrich.entity_connect.large_ns_limit",
151        default: Some("25"),
152    },
153    SettingKey {
154        key: "enrich.entity_description.domain",
155        default: Some("auto"),
156    },
157    SettingKey {
158        key: "enrich.entity_description.grounding_threshold",
159        default: Some("0.12"),
160    },
161    SettingKey {
162        key: "enrich.entity_description.min_corpus_chars",
163        default: Some("40"),
164    },
165    SettingKey {
166        key: "enrich.entity_description.quality_sample",
167        default: Some("50"),
168    },
169    SettingKey {
170        key: "enrich.yield_every_n_items",
171        default: Some("10"),
172    },
173    SettingKey {
174        key: "i18n.lang",
175        default: Some("en"),
176    },
177    SettingKey {
178        key: "ingest.low_memory",
179        default: Some("false"),
180    },
181    SettingKey {
182        key: "limits.max_entities_per_memory",
183        default: Some("50"),
184    },
185    SettingKey {
186        key: "limits.max_relations_per_memory",
187        default: Some("50"),
188    },
189    SettingKey {
190        key: "llm.claude_binary",
191        default: None,
192    },
193    SettingKey {
194        key: "llm.claude_empty_config_dir",
195        default: None,
196    },
197    SettingKey {
198        key: "llm.codex_binary",
199        default: None,
200    },
201    SettingKey {
202        key: "llm.fallback",
203        default: Some("codex,claude,none"),
204    },
205    SettingKey {
206        key: "llm.max_host_concurrency",
207        default: None,
208    },
209    SettingKey {
210        key: "llm.model",
211        default: None,
212    },
213    SettingKey {
214        key: "llm.opencode_binary",
215        default: None,
216    },
217    SettingKey {
218        key: "llm.opencode_model",
219        default: None,
220    },
221    SettingKey {
222        key: "llm.opencode_timeout",
223        default: Some("300"),
224    },
225    SettingKey {
226        key: "llm.probe_timeout_ms",
227        default: Some("800"),
228    },
229    SettingKey {
230        key: "llm.skip_embedding_on_failure",
231        default: Some("false"),
232    },
233    SettingKey {
234        key: "llm.slot_no_wait",
235        default: Some("false"),
236    },
237    SettingKey {
238        key: "llm.slot_wait_secs",
239        default: Some("300"),
240    },
241    SettingKey {
242        key: "log.format",
243        default: Some("pretty"),
244    },
245    SettingKey {
246        key: "log.level",
247        default: Some("warn"),
248    },
249    SettingKey {
250        key: "log.retention_days",
251        default: Some("7"),
252    },
253    SettingKey {
254        key: "log.rotation",
255        default: Some("daily"),
256    },
257    SettingKey {
258        key: "log.to_file",
259        default: Some("false"),
260    },
261    SettingKey {
262        key: "namespace.default",
263        default: Some("global"),
264    },
265    SettingKey {
266        key: "network.chat_url",
267        default: None,
268    },
269    SettingKey {
270        key: "network.embed_url",
271        default: None,
272    },
273    SettingKey {
274        key: "network.openrouter.chat_url",
275        default: Some(crate::constants::DEFAULT_OPENROUTER_CHAT_URL),
276    },
277    SettingKey {
278        key: "network.openrouter.embeddings_url",
279        default: Some(crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL),
280    },
281    SettingKey {
282        key: "parallelism.rayon_threads",
283        default: None,
284    },
285    SettingKey {
286        key: "retry.disable",
287        default: Some("false"),
288    },
289    SettingKey {
290        key: "shutdown.ignore",
291        default: Some("false"),
292    },
293    SettingKey {
294        key: "spawn.skip_preflight",
295        default: Some("false"),
296    },
297    SettingKey {
298        key: "spawn.strict_env_clear",
299        default: Some("false"),
300    },
301    SettingKey {
302        key: "system.max_load_per_ncpu",
303        default: Some("2.0"),
304    },
305];
306
307/// Iterates the registry key names in registration (sorted) order.
308///
309/// Callers that only need names use this instead of reaching into the struct,
310/// so adding a field to [`SettingKey`] does not ripple through the crate.
311pub fn setting_key_names() -> impl Iterator<Item = &'static str> {
312    SETTING_KEYS.iter().map(|entry| entry.key)
313}
314
315/// Setting keys that were advertised historically but never had a reader.
316///
317/// Each tuple maps the obsolete key to its replacement. Present so
318/// [`load_config`] can warn instead of ignoring the value in silence, which
319/// is the failure mode `GAP-SG-79` documents.
320///
321/// These keys are rejected by [`set_setting`]; the warning covers configs
322/// written before the validation existed.
323// GAP-SG-79 / GAP-SG-122: legacy aliases map to the single canonical key.
324// `db.default_path` never took effect (paths always read `db.path`).
325// `paths.cache` was a parallel name for `cache.dir`.
326const LEGACY_SETTING_KEYS: &[(&str, &str)] =
327    &[("db.default_path", "db.path"), ("paths.cache", "cache.dir")];
328
329/// Minimum Jaro-Winkler similarity required to suggest a replacement key.
330///
331/// Below this a suggestion is more confusing than helpful, so the error lists
332/// nothing rather than pointing at an unrelated key.
333const SUGGESTION_THRESHOLD: f64 = 0.7;
334
335/// GAP-SG-90: every literal key passed to [`get_setting`] / [`set_setting`] in
336/// production sources must appear in [`SETTING_KEYS`]. The unit test below
337/// scans the crate source for `get_setting("…")` call sites.
338#[cfg(test)]
339mod setting_keys_drift_tests {
340    use super::SETTING_KEYS;
341
342    #[test]
343    fn setting_keys_covers_get_setting_literals_in_src() {
344        // Walk a fixed list of high-traffic modules (full-tree walk is flaky in
345        // package builds). Keys used only via aliases arrays are covered by
346        // runtime_config unit tests.
347        let sources = [
348            include_str!("paths.rs"),
349            include_str!("i18n/mod.rs"),
350            include_str!("i18n/validation/mod.rs"),
351            include_str!("i18n/validation/messages_a.rs"),
352            include_str!("i18n/validation/messages_b.rs"),
353            include_str!("tz.rs"),
354            include_str!("namespace.rs"),
355            include_str!("retry.rs"),
356            include_str!("lock.rs"),
357            include_str!("llm_slots.rs"),
358            include_str!("system_load.rs"),
359            include_str!("spawn/preflight.rs"),
360            include_str!("spawn/env_whitelist.rs"),
361            include_str!("commands/ingest/mod.rs"),
362            include_str!("commands/enrich/prompts.rs"),
363            include_str!("extract/llm_embedding/mod.rs"),
364            include_str!("lib.rs"),
365            include_str!("main.rs"),
366        ];
367        let registered: std::collections::HashSet<&str> =
368            SETTING_KEYS.iter().map(|e| e.key).collect();
369        let mut missing = Vec::new();
370        for src in sources {
371            for cap in src.split("get_setting(\"").skip(1) {
372                if let Some(end) = cap.find('"') {
373                    let key = &cap[..end];
374                    if key.is_empty() {
375                        continue;
376                    }
377                    if !registered.contains(key) {
378                        missing.push(key.to_string());
379                    }
380                }
381            }
382        }
383        missing.sort();
384        missing.dedup();
385        assert!(
386            missing.is_empty(),
387            "get_setting keys missing from SETTING_KEYS: {missing:?}"
388        );
389    }
390}
391
392/// Returns `true` when `key` belongs to [`SETTING_KEYS`].
393pub fn is_known_setting(key: &str) -> bool {
394    setting_key_names().any(|known| known == key)
395}
396
397/// Returns the closest known key to `key`, when one is similar enough.
398///
399/// Reuses `rapidfuzz` Jaro-Winkler, the same scorer already used for entity
400/// name resolution in [`crate::storage::entities`], instead of introducing a
401/// second similarity implementation.
402fn nearest_setting_key(key: &str) -> Option<&'static str> {
403    setting_key_names()
404        .map(|candidate| {
405            let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
406                key.chars(),
407                candidate.chars(),
408            );
409            (candidate, score)
410        })
411        .filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
412        .max_by(|a, b| a.1.total_cmp(&b.1))
413        .map(|(candidate, _)| candidate)
414}
415
416/// Read an operational setting from XDG config (flag > XDG > default is
417/// applied by callers). Returns `None` when unset.
418pub fn get_setting(key: &str) -> Result<Option<String>, AppError> {
419    let cfg = load_config()?;
420    if let Some(v) = cfg.settings.get(key) {
421        return Ok(Some(v.clone()));
422    }
423    // GAP-SG-122: when the canonical key is missing, fall back to any retired
424    // alias that still maps onto it (e.g. paths.cache → cache.dir).
425    for (legacy, replacement) in LEGACY_SETTING_KEYS {
426        if *replacement == key {
427            if let Some(v) = cfg.settings.get(*legacy) {
428                return Ok(Some(v.clone()));
429            }
430        }
431    }
432    Ok(None)
433}
434
435/// Persist an operational setting in XDG config.toml (G-T-XDG-01).
436pub fn set_setting(key: &str, value: &str) -> Result<(), AppError> {
437    if key.trim().is_empty() {
438        return Err(AppError::Validation("config key must be non-empty".into()));
439    }
440    // GAP-SG-80: an unvalidated insert persisted typos and obsolete keys while
441    // reporting success, so the operator saw the value in `config show` and
442    // assumed it took effect. Reject early instead of storing a silent no-op.
443    if !is_known_setting(key) {
444        if let Some(replacement) = LEGACY_SETTING_KEYS
445            .iter()
446            .find(|(legacy, _)| *legacy == key)
447            .map(|(_, replacement)| *replacement)
448        {
449            return Err(AppError::Validation(validation::config_key_retired(
450                key,
451                replacement,
452            )));
453        }
454        return Err(AppError::Validation(validation::config_key_unknown(
455            key,
456            nearest_setting_key(key),
457        )));
458    }
459    let mut cfg = load_config()?;
460    cfg.settings.insert(key.to_string(), value.to_string());
461    save_config(&cfg)
462}
463
464/// Remove an operational setting from XDG config.toml.
465pub fn unset_setting(key: &str) -> Result<bool, AppError> {
466    let mut cfg = load_config()?;
467    let removed = cfg.settings.remove(key).is_some();
468    if removed {
469        save_config(&cfg)?;
470    }
471    Ok(removed)
472}
473
474/// List all operational settings (no secrets).
475pub fn list_settings() -> Result<std::collections::BTreeMap<String, String>, AppError> {
476    let cfg = load_config()?;
477    Ok(cfg.settings)
478}
479
480/// Resolved key.
481pub struct ResolvedKey {
482    /// Value.
483    pub value: SecretBox<String>,
484    /// Source side of the relationship.
485    pub source: &'static str,
486}
487
488/// Absolute path of `config.toml`.
489///
490/// GAP-SG-98: delegates to [`crate::paths::config_dir`] so `--config-dir` is
491/// honoured. This function used to call [`directories::ProjectDirs`] directly, which made it
492/// a second, independent config-directory resolver that no flag could redirect.
493///
494/// There is no cycle: [`crate::paths::config_dir`] consults only the CLI
495/// override captured in [`crate::runtime_config`], never a `config set` key.
496pub fn config_file_path() -> Result<PathBuf, AppError> {
497    Ok(crate::paths::config_dir()?.join("config.toml"))
498}
499
500/// Load application configuration from the XDG config file.
501pub fn load_config() -> Result<AppConfig, AppError> {
502    let path = config_file_path()?;
503
504    if !path.exists() {
505        return Ok(AppConfig::default());
506    }
507
508    let meta = std::fs::symlink_metadata(&path)?;
509    if meta.file_type().is_symlink() {
510        return Err(AppError::Validation(validation::config_file_is_symlink(
511            &path.display().to_string(),
512        )));
513    }
514
515    #[cfg(unix)]
516    {
517        use std::os::unix::fs::PermissionsExt;
518        let mode = meta.permissions().mode() & 0o777;
519        if mode > 0o600 {
520            tracing::warn!(
521                path = %path.display(),
522                mode = format!("{mode:o}"),
523                "config file permissions are too open; recommend chmod 600"
524            );
525        }
526    }
527
528    let content = std::fs::read_to_string(&path)?;
529    let cfg: AppConfig = toml::from_str(&content).map_err(|e| {
530        AppError::Validation(validation::config_parse_error(
531            &path.display().to_string(),
532            &e,
533        ))
534    })?;
535    warn_on_legacy_settings(&cfg);
536    Ok(cfg)
537}
538
539/// Emits one warning per process for each retired key still present on disk.
540///
541/// `load_config` runs on every [`get_setting`] call, so the warning is gated by
542/// a [`std::sync::Once`] to keep a hot read path from flooding stderr.
543///
544/// The value is deliberately left untouched: `GAP-SG-79` is fixed by making the
545/// dead key visible, not by rewriting a file the operator owns.
546fn warn_on_legacy_settings(cfg: &AppConfig) {
547    static WARNED: std::sync::Once = std::sync::Once::new();
548    if LEGACY_SETTING_KEYS
549        .iter()
550        .all(|(legacy, _)| !cfg.settings.contains_key(*legacy))
551    {
552        return;
553    }
554    WARNED.call_once(|| {
555        for (legacy, replacement) in LEGACY_SETTING_KEYS {
556            if cfg.settings.contains_key(*legacy) {
557                tracing::warn!(
558                    target: "config",
559                    key = legacy,
560                    replacement = replacement,
561                    "config key is never read and has no effect; \
562                     move the value to the replacement key and unset the old one"
563                );
564            }
565        }
566    });
567}
568
569/// Persist application configuration to the XDG config file.
570pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
571    let path = config_file_path()?;
572    let dir = path.parent().ok_or_else(|| {
573        AppError::Validation(validation::config_path_no_parent(
574            &path.display().to_string(),
575        ))
576    })?;
577
578    std::fs::create_dir_all(dir)?;
579
580    #[cfg(unix)]
581    {
582        use std::os::unix::fs::PermissionsExt;
583        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
584    }
585
586    #[cfg(unix)]
587    if path.exists() {
588        use std::os::unix::fs::MetadataExt;
589        let meta = std::fs::metadata(&path)?;
590        let file_uid = meta.uid();
591        let my_uid = unsafe { libc::getuid() };
592        if file_uid != my_uid {
593            return Err(AppError::Validation(validation::config_file_wrong_owner(
594                &path.display().to_string(),
595                file_uid,
596                my_uid,
597            )));
598        }
599    }
600
601    let serialized =
602        toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
603
604    #[cfg(unix)]
605    let old_umask = unsafe { libc::umask(0o077) };
606
607    use std::io::Write;
608    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
609    tmp.write_all(serialized.as_bytes())?;
610    tmp.as_file().sync_all()?;
611
612    #[cfg(unix)]
613    {
614        use std::os::unix::fs::PermissionsExt;
615        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
616    }
617
618    tmp.persist(&path)
619        .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
620
621    #[cfg(unix)]
622    unsafe {
623        libc::umask(old_umask);
624    }
625
626    // fsync parent dir for crash consistency
627    #[cfg(unix)]
628    {
629        let dir_file = std::fs::File::open(dir)?;
630        dir_file.sync_all()?;
631    }
632
633    Ok(())
634}
635
636/// Resolve API key.
637pub fn resolve_api_key(provider: &str, cli_key: Option<&str>) -> Option<ResolvedKey> {
638    // G-T-XDG-04: flag/cli > XDG `config add-key` only. Product env is not read.
639    if let Some(k) = cli_key {
640        if !k.is_empty() {
641            return Some(ResolvedKey {
642                value: SecretBox::new(Box::new(k.to_owned())),
643                source: "cli",
644            });
645        }
646    }
647
648    if let Ok(cfg) = load_config() {
649        if let Some(entry) = cfg.keys.iter().find(|k| k.provider == provider) {
650            return Some(ResolvedKey {
651                value: SecretBox::new(Box::new(entry.value.clone())),
652                source: "config",
653            });
654        }
655    }
656
657    None
658}
659
660/// Compute fingerprint.
661pub fn compute_fingerprint(key: &str) -> String {
662    let hash = blake3::hash(key.as_bytes());
663    hash.to_hex()[..16].to_string()
664}
665
666/// Mask key.
667pub fn mask_key(key: &str) -> String {
668    if key.len() <= 8 {
669        return "****".to_string();
670    }
671    format!("{}...{}", &key[..4], &key[key.len() - 4..])
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use secrecy::ExposeSecret;
678    use tempfile::TempDir;
679
680    #[test]
681    fn compute_fingerprint_deterministic() {
682        let fp1 = compute_fingerprint("sk-or-v1-test-key-12345");
683        let fp2 = compute_fingerprint("sk-or-v1-test-key-12345");
684        assert_eq!(fp1, fp2);
685        assert_eq!(fp1.len(), 16);
686    }
687
688    #[test]
689    fn compute_fingerprint_differs_for_different_keys() {
690        let fp1 = compute_fingerprint("key-a");
691        let fp2 = compute_fingerprint("key-b");
692        assert_ne!(fp1, fp2);
693    }
694
695    #[test]
696    fn mask_key_short() {
697        assert_eq!(mask_key("abcd"), "****");
698        assert_eq!(mask_key("12345678"), "****");
699        assert_eq!(mask_key(""), "****");
700    }
701
702    #[test]
703    fn mask_key_normal() {
704        assert_eq!(mask_key("sk-or-v1-abcdef1234"), "sk-o...1234");
705    }
706
707    #[test]
708    fn load_config_missing_file_returns_default() {
709        let tmp = TempDir::new().unwrap();
710        let nonexistent = tmp.path().join("does-not-exist.toml");
711        assert!(!nonexistent.exists());
712        let cfg = AppConfig::default();
713        assert_eq!(cfg.schema_version, 1);
714        assert!(cfg.keys.is_empty());
715    }
716
717    #[test]
718    fn save_and_load_roundtrip() {
719        let tmp = TempDir::new().unwrap();
720        let config_path = tmp.path().join("config.toml");
721
722        let mut cfg = AppConfig::default();
723        cfg.keys.push(ApiKeyEntry {
724            provider: "openrouter".to_string(),
725            value: "sk-test-key".to_string(),
726            added_at: "2026-01-01T00:00:00Z".to_string(),
727            fingerprint: compute_fingerprint("sk-test-key"),
728        });
729
730        let serialized = toml::to_string_pretty(&cfg).unwrap();
731        std::fs::write(&config_path, &serialized).unwrap();
732
733        let content = std::fs::read_to_string(&config_path).unwrap();
734        let loaded: AppConfig = toml::from_str(&content).unwrap();
735
736        assert_eq!(loaded.schema_version, 1);
737        assert_eq!(loaded.keys.len(), 1);
738        assert_eq!(loaded.keys[0].provider, "openrouter");
739        assert_eq!(loaded.keys[0].value, "sk-test-key");
740    }
741
742    #[test]
743    fn resolve_api_key_cli_wins() {
744        let resolved = resolve_api_key("openrouter", Some("cli-key-value"));
745        assert!(resolved.is_some());
746        let r = resolved.unwrap();
747        assert_eq!(r.source, "cli");
748        assert_eq!(r.value.expose_secret(), "cli-key-value");
749    }
750
751    #[test]
752    fn resolve_api_key_cli_fallback() {
753        let resolved = resolve_api_key("nonexistent-provider", Some("cli-key"));
754        assert!(resolved.is_some());
755        let r = resolved.unwrap();
756        assert_eq!(r.source, "cli");
757        assert_eq!(r.value.expose_secret(), "cli-key");
758    }
759
760    #[test]
761    fn resolve_api_key_none_when_nothing_available() {
762        let resolved = resolve_api_key("totally-unknown-provider-xyz-no-key", None);
763        // Only returns Some if host XDG config has that provider (unlikely).
764        if let Some(r) = resolved {
765            assert_eq!(r.source, "config");
766        }
767    }
768
769    #[test]
770    fn resolve_api_key_ignores_product_env() {
771        // G-T-XDG-04: even if OPENROUTER_API_KEY is set, it must not be used.
772        unsafe {
773            std::env::set_var("OPENROUTER_API_KEY", "env-must-be-ignored");
774        }
775        let resolved = resolve_api_key("openrouter-env-ignore-test-provider", None);
776        assert!(resolved.is_none(), "product env must not supply API keys");
777        unsafe {
778            std::env::remove_var("OPENROUTER_API_KEY");
779        }
780    }
781}