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 {
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
307pub fn setting_key_names() -> impl Iterator<Item = &'static str> {
312 SETTING_KEYS.iter().map(|entry| entry.key)
313}
314
315const LEGACY_SETTING_KEYS: &[(&str, &str)] =
327 &[("db.default_path", "db.path"), ("paths.cache", "cache.dir")];
328
329const SUGGESTION_THRESHOLD: f64 = 0.7;
334
335#[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 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
392pub fn is_known_setting(key: &str) -> bool {
394 setting_key_names().any(|known| known == key)
395}
396
397fn 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
416pub 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 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
435pub 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 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
464pub 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
474pub fn list_settings() -> Result<std::collections::BTreeMap<String, String>, AppError> {
476 let cfg = load_config()?;
477 Ok(cfg.settings)
478}
479
480pub struct ResolvedKey {
482 pub value: SecretBox<String>,
484 pub source: &'static str,
486}
487
488pub fn config_file_path() -> Result<PathBuf, AppError> {
497 Ok(crate::paths::config_dir()?.join("config.toml"))
498}
499
500pub 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
539fn 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
569pub 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 #[cfg(unix)]
628 {
629 let dir_file = std::fs::File::open(dir)?;
630 dir_file.sync_all()?;
631 }
632
633 Ok(())
634}
635
636pub fn resolve_api_key(provider: &str, cli_key: Option<&str>) -> Option<ResolvedKey> {
638 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
660pub fn compute_fingerprint(key: &str) -> String {
662 let hash = blake3::hash(key.as_bytes());
663 hash.to_hex()[..16].to_string()
664}
665
666pub 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 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 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}