1use crate::errors::AppError;
7use crate::i18n::validation;
8use secrecy::SecretBox;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AppConfig {
15 pub schema_version: u32,
17 #[serde(default)]
19 pub keys: Vec<ApiKeyEntry>,
20 #[serde(default)]
24 pub settings: std::collections::BTreeMap<String, String>,
25}
26
27#[derive(Clone, Serialize, Deserialize)]
29pub struct ApiKeyEntry {
30 pub provider: String,
32 pub value: String,
34 pub added_at: String,
36 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
61pub struct SettingKey {
68 pub key: &'static str,
70 pub default: Option<&'static str>,
78}
79
80pub const SETTING_KEYS: &[SettingKey] = &[
97 SettingKey { key: "cache.dir", default: None },
98 SettingKey { key: "cli.max_instances", default: None },
99 SettingKey { key: "db.busy_base_delay_ms", default: Some("300") },
100 SettingKey { key: "db.busy_retries", default: Some("5") },
101 SettingKey { key: "db.path", default: None },
102 SettingKey { key: "db.query_timeout_ms", default: Some("5000") },
103 SettingKey { key: "display.tz", default: Some("UTC") },
104 SettingKey { key: "embedding.batch_size", default: Some("32") },
105 SettingKey { key: "embedding.claude_model", default: None },
106 SettingKey { key: "embedding.codex_model", default: None },
107 SettingKey { key: "embedding.dim", default: Some("1024") },
108 SettingKey { key: "embedding.opencode_model", default: None },
109 SettingKey { key: "enrich.entity_connect.default_limit", default: Some("100") },
110 SettingKey { key: "enrich.entity_connect.large_ns_limit", default: Some("25") },
111 SettingKey { key: "enrich.entity_description.domain", default: Some("auto") },
112 SettingKey { key: "enrich.entity_description.grounding_threshold", default: Some("0.12") },
113 SettingKey { key: "enrich.entity_description.min_corpus_chars", default: Some("40") },
114 SettingKey { key: "enrich.entity_description.quality_sample", default: Some("50") },
115 SettingKey { key: "enrich.yield_every_n_items", default: Some("10") },
116 SettingKey { key: "i18n.lang", default: Some("en") },
117 SettingKey { key: "ingest.low_memory", default: Some("false") },
118 SettingKey { key: "limits.max_entities_per_memory", default: Some("50") },
119 SettingKey { key: "limits.max_relations_per_memory", default: Some("50") },
120 SettingKey { key: "llm.claude_binary", default: None },
121 SettingKey { key: "llm.claude_empty_config_dir", default: None },
122 SettingKey { key: "llm.codex_binary", default: None },
123 SettingKey { key: "llm.fallback", default: Some("codex,claude,none") },
124 SettingKey { key: "llm.max_host_concurrency", default: None },
125 SettingKey { key: "llm.model", default: None },
126 SettingKey { key: "llm.opencode_binary", default: None },
127 SettingKey { key: "llm.opencode_model", default: None },
128 SettingKey { key: "llm.opencode_timeout", default: Some("300") },
129 SettingKey { key: "llm.probe_timeout_ms", default: Some("800") },
130 SettingKey { key: "llm.skip_embedding_on_failure", default: Some("false") },
131 SettingKey { key: "llm.slot_no_wait", default: Some("false") },
132 SettingKey { key: "llm.slot_wait_secs", default: Some("300") },
133 SettingKey { key: "log.format", default: Some("pretty") },
134 SettingKey { key: "log.level", default: Some("warn") },
135 SettingKey { key: "log.retention_days", default: Some("7") },
136 SettingKey { key: "log.rotation", default: Some("daily") },
137 SettingKey { key: "log.to_file", default: Some("false") },
138 SettingKey { key: "namespace.default", default: Some("global") },
139 SettingKey { key: "network.chat_url", default: None },
140 SettingKey { key: "network.embed_url", default: None },
141 SettingKey { key: "network.openrouter.chat_url", default: Some(crate::constants::DEFAULT_OPENROUTER_CHAT_URL) },
142 SettingKey { key: "network.openrouter.embeddings_url", default: Some(crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL) },
143 SettingKey { key: "parallelism.rayon_threads", default: None },
144 SettingKey { key: "retry.disable", default: Some("false") },
145 SettingKey { key: "shutdown.ignore", default: Some("false") },
146 SettingKey { key: "spawn.skip_preflight", default: Some("false") },
147 SettingKey { key: "spawn.strict_env_clear", default: Some("false") },
148 SettingKey { key: "system.max_load_per_ncpu", default: Some("2.0") },
149];
150
151pub fn setting_key_names() -> impl Iterator<Item = &'static str> {
156 SETTING_KEYS.iter().map(|entry| entry.key)
157}
158
159const LEGACY_SETTING_KEYS: &[(&str, &str)] = &[
171 ("db.default_path", "db.path"),
172 ("paths.cache", "cache.dir"),
173];
174
175const SUGGESTION_THRESHOLD: f64 = 0.7;
180
181#[cfg(test)]
185mod setting_keys_drift_tests {
186 use super::SETTING_KEYS;
187
188 #[test]
189 fn setting_keys_covers_get_setting_literals_in_src() {
190 let sources = [
194 include_str!("paths.rs"),
195 include_str!("i18n/mod.rs"),
196 include_str!("i18n/validation/mod.rs"),
197 include_str!("i18n/validation/messages_a.rs"),
198 include_str!("i18n/validation/messages_b.rs"),
199 include_str!("tz.rs"),
200 include_str!("namespace.rs"),
201 include_str!("retry.rs"),
202 include_str!("lock.rs"),
203 include_str!("llm_slots.rs"),
204 include_str!("system_load.rs"),
205 include_str!("spawn/preflight.rs"),
206 include_str!("spawn/env_whitelist.rs"),
207 include_str!("commands/ingest/mod.rs"),
208 include_str!("commands/enrich/prompts.rs"),
209 include_str!("extract/llm_embedding/mod.rs"),
210 include_str!("lib.rs"),
211 include_str!("main.rs"),
212 ];
213 let registered: std::collections::HashSet<&str> =
214 SETTING_KEYS.iter().map(|e| e.key).collect();
215 let mut missing = Vec::new();
216 for src in sources {
217 for cap in src.split("get_setting(\"").skip(1) {
218 if let Some(end) = cap.find('"') {
219 let key = &cap[..end];
220 if key.is_empty() {
221 continue;
222 }
223 if !registered.contains(key) {
224 missing.push(key.to_string());
225 }
226 }
227 }
228 }
229 missing.sort();
230 missing.dedup();
231 assert!(
232 missing.is_empty(),
233 "get_setting keys missing from SETTING_KEYS: {missing:?}"
234 );
235 }
236}
237
238pub fn is_known_setting(key: &str) -> bool {
240 setting_key_names().any(|known| known == key)
241}
242
243fn nearest_setting_key(key: &str) -> Option<&'static str> {
249 setting_key_names()
250 .map(|candidate| {
251 let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
252 key.chars(),
253 candidate.chars(),
254 );
255 (candidate, score)
256 })
257 .filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
258 .max_by(|a, b| a.1.total_cmp(&b.1))
259 .map(|(candidate, _)| candidate)
260}
261
262pub fn get_setting(key: &str) -> Result<Option<String>, AppError> {
265 let cfg = load_config()?;
266 if let Some(v) = cfg.settings.get(key) {
267 return Ok(Some(v.clone()));
268 }
269 for (legacy, replacement) in LEGACY_SETTING_KEYS {
272 if *replacement == key {
273 if let Some(v) = cfg.settings.get(*legacy) {
274 return Ok(Some(v.clone()));
275 }
276 }
277 }
278 Ok(None)
279}
280
281pub fn set_setting(key: &str, value: &str) -> Result<(), AppError> {
283 if key.trim().is_empty() {
284 return Err(AppError::Validation(
285 "config key must be non-empty".into(),
286 ));
287 }
288 if !is_known_setting(key) {
292 if let Some(replacement) = LEGACY_SETTING_KEYS
293 .iter()
294 .find(|(legacy, _)| *legacy == key)
295 .map(|(_, replacement)| *replacement)
296 {
297 return Err(AppError::Validation(validation::config_key_retired(
298 key,
299 replacement,
300 )));
301 }
302 return Err(AppError::Validation(validation::config_key_unknown(
303 key,
304 nearest_setting_key(key),
305 )));
306 }
307 let mut cfg = load_config()?;
308 cfg.settings.insert(key.to_string(), value.to_string());
309 save_config(&cfg)
310}
311
312pub fn unset_setting(key: &str) -> Result<bool, AppError> {
314 let mut cfg = load_config()?;
315 let removed = cfg.settings.remove(key).is_some();
316 if removed {
317 save_config(&cfg)?;
318 }
319 Ok(removed)
320}
321
322pub fn list_settings() -> Result<std::collections::BTreeMap<String, String>, AppError> {
324 let cfg = load_config()?;
325 Ok(cfg.settings)
326}
327
328pub struct ResolvedKey {
330 pub value: SecretBox<String>,
332 pub source: &'static str,
334}
335
336pub fn config_file_path() -> Result<PathBuf, AppError> {
345 Ok(crate::paths::config_dir()?.join("config.toml"))
346}
347
348pub fn load_config() -> Result<AppConfig, AppError> {
350 let path = config_file_path()?;
351
352 if !path.exists() {
353 return Ok(AppConfig::default());
354 }
355
356 let meta = std::fs::symlink_metadata(&path)?;
357 if meta.file_type().is_symlink() {
358 return Err(AppError::Validation(validation::config_file_is_symlink(
359 &path.display().to_string(),
360 )));
361 }
362
363 #[cfg(unix)]
364 {
365 use std::os::unix::fs::PermissionsExt;
366 let mode = meta.permissions().mode() & 0o777;
367 if mode > 0o600 {
368 tracing::warn!(
369 path = %path.display(),
370 mode = format!("{mode:o}"),
371 "config file permissions are too open; recommend chmod 600"
372 );
373 }
374 }
375
376 let content = std::fs::read_to_string(&path)?;
377 let cfg: AppConfig = toml::from_str(&content).map_err(|e| {
378 AppError::Validation(validation::config_parse_error(
379 &path.display().to_string(),
380 &e,
381 ))
382 })?;
383 warn_on_legacy_settings(&cfg);
384 Ok(cfg)
385}
386
387fn warn_on_legacy_settings(cfg: &AppConfig) {
395 static WARNED: std::sync::Once = std::sync::Once::new();
396 if LEGACY_SETTING_KEYS
397 .iter()
398 .all(|(legacy, _)| !cfg.settings.contains_key(*legacy))
399 {
400 return;
401 }
402 WARNED.call_once(|| {
403 for (legacy, replacement) in LEGACY_SETTING_KEYS {
404 if cfg.settings.contains_key(*legacy) {
405 tracing::warn!(
406 target: "config",
407 key = legacy,
408 replacement = replacement,
409 "config key is never read and has no effect; \
410 move the value to the replacement key and unset the old one"
411 );
412 }
413 }
414 });
415}
416
417pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
419 let path = config_file_path()?;
420 let dir = path.parent().ok_or_else(|| {
421 AppError::Validation(validation::config_path_no_parent(
422 &path.display().to_string(),
423 ))
424 })?;
425
426 std::fs::create_dir_all(dir)?;
427
428 #[cfg(unix)]
429 {
430 use std::os::unix::fs::PermissionsExt;
431 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
432 }
433
434 #[cfg(unix)]
435 if path.exists() {
436 use std::os::unix::fs::MetadataExt;
437 let meta = std::fs::metadata(&path)?;
438 let file_uid = meta.uid();
439 let my_uid = unsafe { libc::getuid() };
440 if file_uid != my_uid {
441 return Err(AppError::Validation(validation::config_file_wrong_owner(
442 &path.display().to_string(),
443 file_uid,
444 my_uid,
445 )));
446 }
447 }
448
449 let serialized =
450 toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
451
452 #[cfg(unix)]
453 let old_umask = unsafe { libc::umask(0o077) };
454
455 use std::io::Write;
456 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
457 tmp.write_all(serialized.as_bytes())?;
458 tmp.as_file().sync_all()?;
459
460 #[cfg(unix)]
461 {
462 use std::os::unix::fs::PermissionsExt;
463 std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
464 }
465
466 tmp.persist(&path)
467 .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
468
469 #[cfg(unix)]
470 unsafe {
471 libc::umask(old_umask);
472 }
473
474 #[cfg(unix)]
476 {
477 let dir_file = std::fs::File::open(dir)?;
478 dir_file.sync_all()?;
479 }
480
481 Ok(())
482}
483
484pub fn resolve_api_key(provider: &str, cli_key: Option<&str>) -> Option<ResolvedKey> {
486 if let Some(k) = cli_key {
488 if !k.is_empty() {
489 return Some(ResolvedKey {
490 value: SecretBox::new(Box::new(k.to_owned())),
491 source: "cli",
492 });
493 }
494 }
495
496 if let Ok(cfg) = load_config() {
497 if let Some(entry) = cfg.keys.iter().find(|k| k.provider == provider) {
498 return Some(ResolvedKey {
499 value: SecretBox::new(Box::new(entry.value.clone())),
500 source: "config",
501 });
502 }
503 }
504
505 None
506}
507
508pub fn compute_fingerprint(key: &str) -> String {
510 let hash = blake3::hash(key.as_bytes());
511 hash.to_hex()[..16].to_string()
512}
513
514pub fn mask_key(key: &str) -> String {
516 if key.len() <= 8 {
517 return "****".to_string();
518 }
519 format!("{}...{}", &key[..4], &key[key.len() - 4..])
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use secrecy::ExposeSecret;
526 use tempfile::TempDir;
527
528 #[test]
529 fn compute_fingerprint_deterministic() {
530 let fp1 = compute_fingerprint("sk-or-v1-test-key-12345");
531 let fp2 = compute_fingerprint("sk-or-v1-test-key-12345");
532 assert_eq!(fp1, fp2);
533 assert_eq!(fp1.len(), 16);
534 }
535
536 #[test]
537 fn compute_fingerprint_differs_for_different_keys() {
538 let fp1 = compute_fingerprint("key-a");
539 let fp2 = compute_fingerprint("key-b");
540 assert_ne!(fp1, fp2);
541 }
542
543 #[test]
544 fn mask_key_short() {
545 assert_eq!(mask_key("abcd"), "****");
546 assert_eq!(mask_key("12345678"), "****");
547 assert_eq!(mask_key(""), "****");
548 }
549
550 #[test]
551 fn mask_key_normal() {
552 assert_eq!(mask_key("sk-or-v1-abcdef1234"), "sk-o...1234");
553 }
554
555 #[test]
556 fn load_config_missing_file_returns_default() {
557 let tmp = TempDir::new().unwrap();
558 let nonexistent = tmp.path().join("does-not-exist.toml");
559 assert!(!nonexistent.exists());
560 let cfg = AppConfig::default();
561 assert_eq!(cfg.schema_version, 1);
562 assert!(cfg.keys.is_empty());
563 }
564
565 #[test]
566 fn save_and_load_roundtrip() {
567 let tmp = TempDir::new().unwrap();
568 let config_path = tmp.path().join("config.toml");
569
570 let mut cfg = AppConfig::default();
571 cfg.keys.push(ApiKeyEntry {
572 provider: "openrouter".to_string(),
573 value: "sk-test-key".to_string(),
574 added_at: "2026-01-01T00:00:00Z".to_string(),
575 fingerprint: compute_fingerprint("sk-test-key"),
576 });
577
578 let serialized = toml::to_string_pretty(&cfg).unwrap();
579 std::fs::write(&config_path, &serialized).unwrap();
580
581 let content = std::fs::read_to_string(&config_path).unwrap();
582 let loaded: AppConfig = toml::from_str(&content).unwrap();
583
584 assert_eq!(loaded.schema_version, 1);
585 assert_eq!(loaded.keys.len(), 1);
586 assert_eq!(loaded.keys[0].provider, "openrouter");
587 assert_eq!(loaded.keys[0].value, "sk-test-key");
588 }
589
590 #[test]
591 fn resolve_api_key_cli_wins() {
592 let resolved = resolve_api_key("openrouter", Some("cli-key-value"));
593 assert!(resolved.is_some());
594 let r = resolved.unwrap();
595 assert_eq!(r.source, "cli");
596 assert_eq!(r.value.expose_secret(), "cli-key-value");
597 }
598
599 #[test]
600 fn resolve_api_key_cli_fallback() {
601 let resolved = resolve_api_key("nonexistent-provider", Some("cli-key"));
602 assert!(resolved.is_some());
603 let r = resolved.unwrap();
604 assert_eq!(r.source, "cli");
605 assert_eq!(r.value.expose_secret(), "cli-key");
606 }
607
608 #[test]
609 fn resolve_api_key_none_when_nothing_available() {
610 let resolved = resolve_api_key("totally-unknown-provider-xyz-no-key", None);
611 if let Some(r) = resolved {
613 assert_eq!(r.source, "config");
614 }
615 }
616
617 #[test]
618 fn resolve_api_key_ignores_product_env() {
619 unsafe {
621 std::env::set_var("OPENROUTER_API_KEY", "env-must-be-ignored");
622 }
623 let resolved = resolve_api_key("openrouter-env-ignore-test-provider", None);
624 assert!(
625 resolved.is_none(),
626 "product env must not supply API keys"
627 );
628 unsafe {
629 std::env::remove_var("OPENROUTER_API_KEY");
630 }
631 }
632}