1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7pub use crate::graph::confidence::ProvenanceWeights;
10
11const DEFAULT_MAX_ENTRIES: usize = 5;
12const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
13const CONFIG_FILE: &str = ".recall-echo.toml";
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Provider {
21 Anthropic,
22 Openai,
23 ClaudeCode,
24}
25
26impl Provider {
27 #[must_use]
28 pub fn default_model(&self) -> &'static str {
29 match self {
30 Provider::Anthropic => "claude-haiku-4-5-20251001",
31 Provider::Openai => "llama3.2",
32 Provider::ClaudeCode => "",
33 }
34 }
35
36 #[must_use]
37 pub fn default_api_base(&self) -> &'static str {
38 match self {
39 Provider::Anthropic => "https://api.anthropic.com/v1/messages",
40 Provider::Openai => "http://localhost:11434/v1",
41 Provider::ClaudeCode => "",
42 }
43 }
44
45 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
46 match s.to_lowercase().as_str() {
47 "anthropic" | "claude" => Ok(Provider::Anthropic),
48 "openai" | "ollama" => Ok(Provider::Openai),
49 "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
50 other => Err(crate::error::RecallError::Config(format!(
51 "unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
52 ))),
53 }
54 }
55}
56
57impl fmt::Display for Provider {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Provider::Anthropic => write!(f, "anthropic"),
61 Provider::Openai => write!(f, "openai"),
62 Provider::ClaudeCode => write!(f, "claude-code"),
63 }
64 }
65}
66
67#[derive(Debug, Default, Serialize, Deserialize)]
70pub struct Config {
71 #[serde(default)]
72 pub ephemeral: EphemeralConfig,
73 #[serde(default)]
74 pub llm: LlmSection,
75 #[serde(default)]
76 pub pipeline: Option<PipelineSection>,
77 #[serde(default)]
78 pub graph: Option<GraphSection>,
79 #[serde(default)]
80 pub serve: ServeSection,
81}
82
83#[derive(Debug, Serialize, Deserialize)]
84pub struct EphemeralConfig {
85 #[serde(default = "default_max_entries")]
86 pub max_entries: usize,
87}
88
89impl Default for EphemeralConfig {
90 fn default() -> Self {
91 Self {
92 max_entries: DEFAULT_MAX_ENTRIES,
93 }
94 }
95}
96
97fn default_max_entries() -> usize {
98 DEFAULT_MAX_ENTRIES
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct LlmSection {
103 #[serde(default = "default_provider")]
104 pub provider: Provider,
105 #[serde(default)]
106 pub model: String,
107 #[serde(default)]
108 pub api_base: String,
109}
110
111impl Default for LlmSection {
112 fn default() -> Self {
113 Self {
114 provider: Provider::Anthropic,
115 model: String::new(),
116 api_base: String::new(),
117 }
118 }
119}
120
121impl LlmSection {
122 #[must_use]
124 pub fn resolved_model(&self) -> &str {
125 if self.model.is_empty() {
126 self.provider.default_model()
127 } else {
128 &self.model
129 }
130 }
131
132 #[must_use]
134 pub fn resolved_api_base(&self) -> &str {
135 if self.api_base.is_empty() {
136 self.provider.default_api_base()
137 } else {
138 &self.api_base
139 }
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PipelineSection {
145 #[serde(default)]
147 pub docs_dir: Option<String>,
148 #[serde(default)]
150 pub auto_sync: Option<bool>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GraphSection {
155 #[serde(default = "default_graph_mode")]
157 pub mode: String,
158 #[serde(default = "default_graph_url")]
160 pub url: String,
161 #[serde(default = "default_graph_namespace")]
163 pub namespace: String,
164 #[serde(default)]
166 pub database: String,
167 #[serde(default)]
169 pub username: String,
170 #[serde(default)]
172 pub password_file: String,
173 #[serde(default)]
179 pub scoring: GraphScoringConfig,
180 #[serde(default)]
186 pub provenance: ProvenanceWeights,
187}
188
189impl Default for GraphSection {
190 fn default() -> Self {
191 Self {
192 mode: default_graph_mode(),
193 url: default_graph_url(),
194 namespace: default_graph_namespace(),
195 database: String::new(),
196 username: String::new(),
197 password_file: String::new(),
198 scoring: GraphScoringConfig::default(),
199 provenance: ProvenanceWeights::default(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(default)]
211pub struct ServeSection {
212 pub socket_path: Option<String>,
215 pub idle_timeout_secs: u64,
218}
219
220impl Default for ServeSection {
221 fn default() -> Self {
222 Self {
223 socket_path: None,
224 idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default)]
249pub struct GraphScoringConfig {
250 pub weight_semantic: f64,
252 pub weight_hotness: f64,
254 pub weight_utility: f64,
256}
257
258impl Default for GraphScoringConfig {
259 fn default() -> Self {
260 Self {
261 weight_semantic: 0.45,
262 weight_hotness: 0.30,
263 weight_utility: 0.25,
264 }
265 }
266}
267
268fn default_graph_mode() -> String {
269 "embedded".to_string()
270}
271
272fn default_graph_url() -> String {
273 "ws://localhost:8787".to_string()
274}
275
276fn default_graph_namespace() -> String {
277 "nullarc".to_string()
278}
279
280fn default_provider() -> Provider {
281 Provider::Anthropic
282}
283
284#[must_use]
288pub fn config_path(base: &Path) -> std::path::PathBuf {
289 base.join(CONFIG_FILE)
290}
291
292#[must_use]
295pub fn load_from_dir(dir: &Path) -> Config {
296 load(dir)
297}
298
299#[must_use]
302pub fn load(base: &Path) -> Config {
303 let path = config_path(base);
304 if !path.exists() {
305 return Config::default();
306 }
307
308 let content = match fs::read_to_string(&path) {
309 Ok(c) => c,
310 Err(_) => return Config::default(),
311 };
312
313 match toml::from_str(&content) {
314 Ok(cfg) => validate(cfg),
315 Err(_) => Config::default(),
316 }
317}
318
319pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
321 let path = config_path(base);
322 let content = toml::to_string_pretty(config)?;
323 fs::write(&path, content)?;
324 Ok(())
325}
326
327#[must_use]
329pub fn exists(base: &Path) -> bool {
330 config_path(base).exists()
331}
332
333fn validate(mut cfg: Config) -> Config {
334 if !(1..=50).contains(&cfg.ephemeral.max_entries) {
335 cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
336 }
337 cfg
338}
339
340impl Config {
343 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
345 use crate::error::RecallError;
346 match key {
347 "llm.provider" | "provider" => {
348 let provider = Provider::from_str_loose(value)?;
349 self.llm.model = String::new();
351 self.llm.api_base = String::new();
352 self.llm.provider = provider;
353 Ok(())
354 }
355 "llm.model" | "model" => {
356 self.llm.model = value.to_string();
357 Ok(())
358 }
359 "llm.api_base" | "api_base" => {
360 self.llm.api_base = value.to_string();
361 Ok(())
362 }
363 "ephemeral.max_entries" => {
364 let n: usize = value
365 .parse()
366 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
367 if !(1..=50).contains(&n) {
368 return Err(RecallError::Config(
369 "max_entries must be between 1 and 50".into(),
370 ));
371 }
372 self.ephemeral.max_entries = n;
373 Ok(())
374 }
375 "pipeline.docs_dir" => {
376 let section = self.pipeline.get_or_insert(PipelineSection {
377 docs_dir: None,
378 auto_sync: None,
379 });
380 section.docs_dir = Some(value.to_string());
381 Ok(())
382 }
383 "pipeline.auto_sync" => {
384 let b: bool = value
385 .parse()
386 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
387 let section = self.pipeline.get_or_insert(PipelineSection {
388 docs_dir: None,
389 auto_sync: None,
390 });
391 section.auto_sync = Some(b);
392 Ok(())
393 }
394 "serve.idle_timeout_secs" => {
395 let secs: u64 = value
396 .parse()
397 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
398 self.serve.idle_timeout_secs = secs;
399 Ok(())
400 }
401 "serve.socket_path" => {
402 self.serve.socket_path = if value.trim().is_empty() {
403 None
404 } else {
405 Some(value.to_string())
406 };
407 Ok(())
408 }
409 "graph.provenance.weight_external" => {
410 self.graph_section().provenance.weight_external = parse_weight(value)?;
411 Ok(())
412 }
413 "graph.provenance.weight_user" => {
414 self.graph_section().provenance.weight_user = parse_weight(value)?;
415 Ok(())
416 }
417 "graph.provenance.weight_self" => {
418 self.graph_section().provenance.weight_self = parse_weight(value)?;
419 Ok(())
420 }
421 other => Err(RecallError::Config(format!("unknown config key: {other}"))),
422 }
423 }
424
425 fn graph_section(&mut self) -> &mut GraphSection {
427 self.graph.get_or_insert_with(GraphSection::default)
428 }
429}
430
431fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
435 use crate::error::RecallError;
436 let weight: f64 = value
437 .parse()
438 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
439 if !weight.is_finite() || weight < 0.0 {
440 return Err(RecallError::Config(format!(
441 "evidence weight must be finite and non-negative, got {value}"
442 )));
443 }
444 Ok(weight)
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn default_config() {
453 let cfg = Config::default();
454 assert_eq!(cfg.ephemeral.max_entries, 5);
455 assert_eq!(cfg.llm.provider, Provider::Anthropic);
456 assert!(cfg.llm.model.is_empty());
457 }
458
459 #[test]
460 fn parse_ephemeral_only() {
461 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
462 assert_eq!(cfg.ephemeral.max_entries, 10);
463 assert_eq!(cfg.llm.provider, Provider::Anthropic);
464 }
465
466 #[test]
467 fn graph_mode_defaults_to_embedded() {
468 let cfg: Config = toml::from_str("[graph]\n").unwrap();
469 assert_eq!(cfg.graph.unwrap().mode, "embedded");
470 }
471
472 #[test]
473 fn graph_mode_parses_server() {
474 let cfg: Config =
475 toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
476 let g = cfg.graph.unwrap();
477 assert_eq!(g.mode, "server");
478 assert_eq!(g.url, "ws://db.local:8787");
479 }
480
481 #[test]
482 fn serve_defaults_when_section_absent() {
483 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
484 assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
485 assert!(cfg.serve.socket_path.is_none());
486 }
487
488 #[test]
489 fn serve_section_parses_overrides() {
490 let cfg: Config = toml::from_str(
491 "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
492 )
493 .unwrap();
494 assert_eq!(cfg.serve.idle_timeout_secs, 60);
495 assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
496 }
497
498 #[test]
499 fn set_key_serve_idle_timeout() {
500 let mut cfg = Config::default();
501 cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
502 assert_eq!(cfg.serve.idle_timeout_secs, 120);
503 assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
504 }
505
506 #[test]
507 fn parse_llm_section() {
508 let cfg: Config = toml::from_str(
509 "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
510 )
511 .unwrap();
512 assert_eq!(cfg.llm.provider, Provider::Openai);
513 assert_eq!(cfg.llm.model, "llama3.1");
514 assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
515 }
516
517 #[test]
518 fn parse_claude_code_provider() {
519 let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
520 assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
521 }
522
523 #[test]
524 fn resolved_defaults() {
525 let llm = LlmSection::default();
526 assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
527 assert_eq!(
528 llm.resolved_api_base(),
529 "https://api.anthropic.com/v1/messages"
530 );
531 }
532
533 #[test]
534 fn resolved_custom_overrides_default() {
535 let llm = LlmSection {
536 provider: Provider::Openai,
537 model: "mistral-7b".into(),
538 api_base: String::new(),
539 };
540 assert_eq!(llm.resolved_model(), "mistral-7b");
541 assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
542 }
543
544 #[test]
545 fn round_trip_toml() {
546 let cfg = Config {
547 ephemeral: EphemeralConfig { max_entries: 3 },
548 llm: LlmSection {
549 provider: Provider::Openai,
550 model: "llama3.2".into(),
551 api_base: "http://localhost:11434/v1".into(),
552 },
553 pipeline: None,
554 graph: None,
555 serve: ServeSection::default(),
556 };
557 let s = toml::to_string_pretty(&cfg).unwrap();
558 let parsed: Config = toml::from_str(&s).unwrap();
559 assert_eq!(parsed.ephemeral.max_entries, 3);
560 assert_eq!(parsed.llm.provider, Provider::Openai);
561 assert_eq!(parsed.llm.model, "llama3.2");
562 }
563
564 #[test]
565 fn set_key_provider() {
566 let mut cfg = Config::default();
567 cfg.set_key("llm.provider", "ollama").unwrap();
568 assert_eq!(cfg.llm.provider, Provider::Openai);
569 assert!(cfg.llm.model.is_empty());
570 }
571
572 #[test]
573 fn set_key_model() {
574 let mut cfg = Config::default();
575 cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
576 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
577 }
578
579 #[test]
580 fn set_key_unknown_fails() {
581 let mut cfg = Config::default();
582 assert!(cfg.set_key("nonexistent.key", "value").is_err());
583 }
584
585 #[test]
586 fn provider_from_str_loose() {
587 assert_eq!(
588 Provider::from_str_loose("ollama").unwrap(),
589 Provider::Openai
590 );
591 assert_eq!(
592 Provider::from_str_loose("claude").unwrap(),
593 Provider::Anthropic
594 );
595 assert_eq!(
596 Provider::from_str_loose("claude-code").unwrap(),
597 Provider::ClaudeCode
598 );
599 assert!(Provider::from_str_loose("unknown").is_err());
600 }
601
602 #[test]
603 fn save_and_load() {
604 let tmp = tempfile::tempdir().unwrap();
605 let cfg = Config {
606 ephemeral: EphemeralConfig { max_entries: 7 },
607 llm: LlmSection {
608 provider: Provider::ClaudeCode,
609 model: String::new(),
610 api_base: String::new(),
611 },
612 pipeline: None,
613 graph: None,
614 serve: ServeSection::default(),
615 };
616 save(tmp.path(), &cfg).unwrap();
617 let loaded = load(tmp.path());
618 assert_eq!(loaded.ephemeral.max_entries, 7);
619 assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
620 }
621
622 #[test]
623 fn load_nonexistent_file() {
624 let tmp = tempfile::tempdir().unwrap();
625 let cfg = load(tmp.path());
626 assert_eq!(cfg.ephemeral.max_entries, 5);
627 }
628
629 #[test]
630 fn validate_out_of_range() {
631 let cfg = validate(Config {
632 ephemeral: EphemeralConfig { max_entries: 100 },
633 llm: LlmSection::default(),
634 pipeline: None,
635 graph: None,
636 serve: ServeSection::default(),
637 });
638 assert_eq!(cfg.ephemeral.max_entries, 5);
639 }
640
641 #[test]
642 fn graph_scoring_defaults_match_legacy_hardcodes() {
643 let scoring = GraphScoringConfig::default();
644 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
645 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
646 assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
647 }
648
649 #[test]
650 fn graph_scoring_partial_toml_fills_defaults() {
651 let scoring: GraphScoringConfig =
652 toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
653 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
654 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
655 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
656 }
657
658 #[test]
659 fn graph_scoring_empty_section_yields_defaults() {
660 let section: GraphSection = toml::from_str("").expect("parse empty graph section");
661 let defaults = GraphScoringConfig::default();
662 assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
663 assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
664 assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
665 }
666
667 #[test]
668 fn graph_provenance_defaults_when_section_absent() {
669 let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
670 let defaults = ProvenanceWeights::default();
671 assert_eq!(section.provenance, defaults);
672 assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
673 assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
674 assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
675 }
676
677 #[test]
678 fn graph_provenance_partial_toml_fills_defaults() {
679 let cfg: Config =
680 toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
681 let provenance = cfg.graph.expect("graph section present").provenance;
682 assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
683 assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
684 assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
685 }
686
687 #[test]
688 fn set_key_provenance_weights() {
689 let mut cfg = Config::default();
690 cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
691 cfg.set_key("graph.provenance.weight_user", "0").unwrap();
692 cfg.set_key("graph.provenance.weight_external", "1.5")
693 .unwrap();
694
695 let provenance = cfg
696 .graph
697 .as_ref()
698 .expect("graph section created")
699 .provenance;
700 assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
701 assert!(provenance.weight_user.abs() < f64::EPSILON);
702 assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
703
704 assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
705 assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
706 }
707
708 #[test]
709 fn provenance_weights_round_trip_through_toml() {
710 let mut cfg = Config::default();
711 cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
712 let rendered = toml::to_string_pretty(&cfg).expect("render");
713 let parsed: Config = toml::from_str(&rendered).expect("reparse");
714 assert_eq!(
715 parsed.graph.expect("graph section survives").provenance,
716 ProvenanceWeights::default()
717 );
718 }
719
720 #[test]
721 fn graph_scoring_nested_under_graph() {
722 let cfg: Config = toml::from_str(
723 "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
724 )
725 .expect("parse nested scoring");
726 let scoring = cfg.graph.expect("graph section present").scoring;
727 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
728 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
729 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
730 }
731}