1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7const DEFAULT_MAX_ENTRIES: usize = 5;
8const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
9const CONFIG_FILE: &str = ".recall-echo.toml";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Provider {
17 Anthropic,
18 Openai,
19 ClaudeCode,
20}
21
22impl Provider {
23 #[must_use]
24 pub fn default_model(&self) -> &'static str {
25 match self {
26 Provider::Anthropic => "claude-haiku-4-5-20251001",
27 Provider::Openai => "llama3.2",
28 Provider::ClaudeCode => "",
29 }
30 }
31
32 #[must_use]
33 pub fn default_api_base(&self) -> &'static str {
34 match self {
35 Provider::Anthropic => "https://api.anthropic.com/v1/messages",
36 Provider::Openai => "http://localhost:11434/v1",
37 Provider::ClaudeCode => "",
38 }
39 }
40
41 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
42 match s.to_lowercase().as_str() {
43 "anthropic" | "claude" => Ok(Provider::Anthropic),
44 "openai" | "ollama" => Ok(Provider::Openai),
45 "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
46 other => Err(crate::error::RecallError::Config(format!(
47 "unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
48 ))),
49 }
50 }
51}
52
53impl fmt::Display for Provider {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 Provider::Anthropic => write!(f, "anthropic"),
57 Provider::Openai => write!(f, "openai"),
58 Provider::ClaudeCode => write!(f, "claude-code"),
59 }
60 }
61}
62
63#[derive(Debug, Default, Serialize, Deserialize)]
66pub struct Config {
67 #[serde(default)]
68 pub ephemeral: EphemeralConfig,
69 #[serde(default)]
70 pub llm: LlmSection,
71 #[serde(default)]
72 pub pipeline: Option<PipelineSection>,
73 #[serde(default)]
74 pub graph: Option<GraphSection>,
75 #[serde(default)]
76 pub serve: ServeSection,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80pub struct EphemeralConfig {
81 #[serde(default = "default_max_entries")]
82 pub max_entries: usize,
83}
84
85impl Default for EphemeralConfig {
86 fn default() -> Self {
87 Self {
88 max_entries: DEFAULT_MAX_ENTRIES,
89 }
90 }
91}
92
93fn default_max_entries() -> usize {
94 DEFAULT_MAX_ENTRIES
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct LlmSection {
99 #[serde(default = "default_provider")]
100 pub provider: Provider,
101 #[serde(default)]
102 pub model: String,
103 #[serde(default)]
104 pub api_base: String,
105}
106
107impl Default for LlmSection {
108 fn default() -> Self {
109 Self {
110 provider: Provider::Anthropic,
111 model: String::new(),
112 api_base: String::new(),
113 }
114 }
115}
116
117impl LlmSection {
118 #[must_use]
120 pub fn resolved_model(&self) -> &str {
121 if self.model.is_empty() {
122 self.provider.default_model()
123 } else {
124 &self.model
125 }
126 }
127
128 #[must_use]
130 pub fn resolved_api_base(&self) -> &str {
131 if self.api_base.is_empty() {
132 self.provider.default_api_base()
133 } else {
134 &self.api_base
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PipelineSection {
141 #[serde(default)]
143 pub docs_dir: Option<String>,
144 #[serde(default)]
146 pub auto_sync: Option<bool>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GraphSection {
151 #[serde(default = "default_graph_mode")]
153 pub mode: String,
154 #[serde(default = "default_graph_url")]
156 pub url: String,
157 #[serde(default = "default_graph_namespace")]
159 pub namespace: String,
160 #[serde(default)]
162 pub database: String,
163 #[serde(default)]
165 pub username: String,
166 #[serde(default)]
168 pub password_file: String,
169 #[serde(default)]
175 pub scoring: GraphScoringConfig,
176}
177
178impl Default for GraphSection {
179 fn default() -> Self {
180 Self {
181 mode: default_graph_mode(),
182 url: default_graph_url(),
183 namespace: default_graph_namespace(),
184 database: String::new(),
185 username: String::new(),
186 password_file: String::new(),
187 scoring: GraphScoringConfig::default(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct ServeSection {
200 pub socket_path: Option<String>,
203 pub idle_timeout_secs: u64,
206}
207
208impl Default for ServeSection {
209 fn default() -> Self {
210 Self {
211 socket_path: None,
212 idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(default)]
237pub struct GraphScoringConfig {
238 pub weight_semantic: f64,
240 pub weight_hotness: f64,
242 pub weight_utility: f64,
244}
245
246impl Default for GraphScoringConfig {
247 fn default() -> Self {
248 Self {
249 weight_semantic: 0.45,
250 weight_hotness: 0.30,
251 weight_utility: 0.25,
252 }
253 }
254}
255
256fn default_graph_mode() -> String {
257 "embedded".to_string()
258}
259
260fn default_graph_url() -> String {
261 "ws://localhost:8787".to_string()
262}
263
264fn default_graph_namespace() -> String {
265 "nullarc".to_string()
266}
267
268fn default_provider() -> Provider {
269 Provider::Anthropic
270}
271
272#[must_use]
276pub fn config_path(base: &Path) -> std::path::PathBuf {
277 base.join(CONFIG_FILE)
278}
279
280#[must_use]
283pub fn load_from_dir(dir: &Path) -> Config {
284 load(dir)
285}
286
287#[must_use]
290pub fn load(base: &Path) -> Config {
291 let path = config_path(base);
292 if !path.exists() {
293 return Config::default();
294 }
295
296 let content = match fs::read_to_string(&path) {
297 Ok(c) => c,
298 Err(_) => return Config::default(),
299 };
300
301 match toml::from_str(&content) {
302 Ok(cfg) => validate(cfg),
303 Err(_) => Config::default(),
304 }
305}
306
307pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
309 let path = config_path(base);
310 let content = toml::to_string_pretty(config)?;
311 fs::write(&path, content)?;
312 Ok(())
313}
314
315#[must_use]
317pub fn exists(base: &Path) -> bool {
318 config_path(base).exists()
319}
320
321fn validate(mut cfg: Config) -> Config {
322 if !(1..=50).contains(&cfg.ephemeral.max_entries) {
323 cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
324 }
325 cfg
326}
327
328impl Config {
331 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
333 use crate::error::RecallError;
334 match key {
335 "llm.provider" | "provider" => {
336 let provider = Provider::from_str_loose(value)?;
337 self.llm.model = String::new();
339 self.llm.api_base = String::new();
340 self.llm.provider = provider;
341 Ok(())
342 }
343 "llm.model" | "model" => {
344 self.llm.model = value.to_string();
345 Ok(())
346 }
347 "llm.api_base" | "api_base" => {
348 self.llm.api_base = value.to_string();
349 Ok(())
350 }
351 "ephemeral.max_entries" => {
352 let n: usize = value
353 .parse()
354 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
355 if !(1..=50).contains(&n) {
356 return Err(RecallError::Config(
357 "max_entries must be between 1 and 50".into(),
358 ));
359 }
360 self.ephemeral.max_entries = n;
361 Ok(())
362 }
363 "pipeline.docs_dir" => {
364 let section = self.pipeline.get_or_insert(PipelineSection {
365 docs_dir: None,
366 auto_sync: None,
367 });
368 section.docs_dir = Some(value.to_string());
369 Ok(())
370 }
371 "pipeline.auto_sync" => {
372 let b: bool = value
373 .parse()
374 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
375 let section = self.pipeline.get_or_insert(PipelineSection {
376 docs_dir: None,
377 auto_sync: None,
378 });
379 section.auto_sync = Some(b);
380 Ok(())
381 }
382 "serve.idle_timeout_secs" => {
383 let secs: u64 = value
384 .parse()
385 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
386 self.serve.idle_timeout_secs = secs;
387 Ok(())
388 }
389 "serve.socket_path" => {
390 self.serve.socket_path = if value.trim().is_empty() {
391 None
392 } else {
393 Some(value.to_string())
394 };
395 Ok(())
396 }
397 other => Err(RecallError::Config(format!("unknown config key: {other}"))),
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn default_config() {
408 let cfg = Config::default();
409 assert_eq!(cfg.ephemeral.max_entries, 5);
410 assert_eq!(cfg.llm.provider, Provider::Anthropic);
411 assert!(cfg.llm.model.is_empty());
412 }
413
414 #[test]
415 fn parse_ephemeral_only() {
416 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
417 assert_eq!(cfg.ephemeral.max_entries, 10);
418 assert_eq!(cfg.llm.provider, Provider::Anthropic);
419 }
420
421 #[test]
422 fn graph_mode_defaults_to_embedded() {
423 let cfg: Config = toml::from_str("[graph]\n").unwrap();
424 assert_eq!(cfg.graph.unwrap().mode, "embedded");
425 }
426
427 #[test]
428 fn graph_mode_parses_server() {
429 let cfg: Config =
430 toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
431 let g = cfg.graph.unwrap();
432 assert_eq!(g.mode, "server");
433 assert_eq!(g.url, "ws://db.local:8787");
434 }
435
436 #[test]
437 fn serve_defaults_when_section_absent() {
438 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
439 assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
440 assert!(cfg.serve.socket_path.is_none());
441 }
442
443 #[test]
444 fn serve_section_parses_overrides() {
445 let cfg: Config = toml::from_str(
446 "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
447 )
448 .unwrap();
449 assert_eq!(cfg.serve.idle_timeout_secs, 60);
450 assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
451 }
452
453 #[test]
454 fn set_key_serve_idle_timeout() {
455 let mut cfg = Config::default();
456 cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
457 assert_eq!(cfg.serve.idle_timeout_secs, 120);
458 assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
459 }
460
461 #[test]
462 fn parse_llm_section() {
463 let cfg: Config = toml::from_str(
464 "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
465 )
466 .unwrap();
467 assert_eq!(cfg.llm.provider, Provider::Openai);
468 assert_eq!(cfg.llm.model, "llama3.1");
469 assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
470 }
471
472 #[test]
473 fn parse_claude_code_provider() {
474 let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
475 assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
476 }
477
478 #[test]
479 fn resolved_defaults() {
480 let llm = LlmSection::default();
481 assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
482 assert_eq!(
483 llm.resolved_api_base(),
484 "https://api.anthropic.com/v1/messages"
485 );
486 }
487
488 #[test]
489 fn resolved_custom_overrides_default() {
490 let llm = LlmSection {
491 provider: Provider::Openai,
492 model: "mistral-7b".into(),
493 api_base: String::new(),
494 };
495 assert_eq!(llm.resolved_model(), "mistral-7b");
496 assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
497 }
498
499 #[test]
500 fn round_trip_toml() {
501 let cfg = Config {
502 ephemeral: EphemeralConfig { max_entries: 3 },
503 llm: LlmSection {
504 provider: Provider::Openai,
505 model: "llama3.2".into(),
506 api_base: "http://localhost:11434/v1".into(),
507 },
508 pipeline: None,
509 graph: None,
510 serve: ServeSection::default(),
511 };
512 let s = toml::to_string_pretty(&cfg).unwrap();
513 let parsed: Config = toml::from_str(&s).unwrap();
514 assert_eq!(parsed.ephemeral.max_entries, 3);
515 assert_eq!(parsed.llm.provider, Provider::Openai);
516 assert_eq!(parsed.llm.model, "llama3.2");
517 }
518
519 #[test]
520 fn set_key_provider() {
521 let mut cfg = Config::default();
522 cfg.set_key("llm.provider", "ollama").unwrap();
523 assert_eq!(cfg.llm.provider, Provider::Openai);
524 assert!(cfg.llm.model.is_empty());
525 }
526
527 #[test]
528 fn set_key_model() {
529 let mut cfg = Config::default();
530 cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
531 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
532 }
533
534 #[test]
535 fn set_key_unknown_fails() {
536 let mut cfg = Config::default();
537 assert!(cfg.set_key("nonexistent.key", "value").is_err());
538 }
539
540 #[test]
541 fn provider_from_str_loose() {
542 assert_eq!(
543 Provider::from_str_loose("ollama").unwrap(),
544 Provider::Openai
545 );
546 assert_eq!(
547 Provider::from_str_loose("claude").unwrap(),
548 Provider::Anthropic
549 );
550 assert_eq!(
551 Provider::from_str_loose("claude-code").unwrap(),
552 Provider::ClaudeCode
553 );
554 assert!(Provider::from_str_loose("unknown").is_err());
555 }
556
557 #[test]
558 fn save_and_load() {
559 let tmp = tempfile::tempdir().unwrap();
560 let cfg = Config {
561 ephemeral: EphemeralConfig { max_entries: 7 },
562 llm: LlmSection {
563 provider: Provider::ClaudeCode,
564 model: String::new(),
565 api_base: String::new(),
566 },
567 pipeline: None,
568 graph: None,
569 serve: ServeSection::default(),
570 };
571 save(tmp.path(), &cfg).unwrap();
572 let loaded = load(tmp.path());
573 assert_eq!(loaded.ephemeral.max_entries, 7);
574 assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
575 }
576
577 #[test]
578 fn load_nonexistent_file() {
579 let tmp = tempfile::tempdir().unwrap();
580 let cfg = load(tmp.path());
581 assert_eq!(cfg.ephemeral.max_entries, 5);
582 }
583
584 #[test]
585 fn validate_out_of_range() {
586 let cfg = validate(Config {
587 ephemeral: EphemeralConfig { max_entries: 100 },
588 llm: LlmSection::default(),
589 pipeline: None,
590 graph: None,
591 serve: ServeSection::default(),
592 });
593 assert_eq!(cfg.ephemeral.max_entries, 5);
594 }
595
596 #[test]
597 fn graph_scoring_defaults_match_legacy_hardcodes() {
598 let scoring = GraphScoringConfig::default();
599 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
600 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
601 assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
602 }
603
604 #[test]
605 fn graph_scoring_partial_toml_fills_defaults() {
606 let scoring: GraphScoringConfig =
607 toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
608 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
609 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
610 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
611 }
612
613 #[test]
614 fn graph_scoring_empty_section_yields_defaults() {
615 let section: GraphSection = toml::from_str("").expect("parse empty graph section");
616 let defaults = GraphScoringConfig::default();
617 assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
618 assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
619 assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
620 }
621
622 #[test]
623 fn graph_scoring_nested_under_graph() {
624 let cfg: Config = toml::from_str(
625 "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
626 )
627 .expect("parse nested scoring");
628 let scoring = cfg.graph.expect("graph section present").scoring;
629 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
630 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
631 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
632 }
633}