1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7const DEFAULT_MAX_ENTRIES: usize = 5;
8const CONFIG_FILE: &str = ".recall-echo.toml";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum Provider {
16 Anthropic,
17 Openai,
18 ClaudeCode,
19}
20
21impl Provider {
22 #[must_use]
23 pub fn default_model(&self) -> &'static str {
24 match self {
25 Provider::Anthropic => "claude-haiku-4-5-20251001",
26 Provider::Openai => "llama3.2",
27 Provider::ClaudeCode => "",
28 }
29 }
30
31 #[must_use]
32 pub fn default_api_base(&self) -> &'static str {
33 match self {
34 Provider::Anthropic => "https://api.anthropic.com/v1/messages",
35 Provider::Openai => "http://localhost:11434/v1",
36 Provider::ClaudeCode => "",
37 }
38 }
39
40 pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
41 match s.to_lowercase().as_str() {
42 "anthropic" | "claude" => Ok(Provider::Anthropic),
43 "openai" | "ollama" => Ok(Provider::Openai),
44 "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
45 other => Err(crate::error::RecallError::Config(format!(
46 "unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
47 ))),
48 }
49 }
50}
51
52impl fmt::Display for Provider {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 match self {
55 Provider::Anthropic => write!(f, "anthropic"),
56 Provider::Openai => write!(f, "openai"),
57 Provider::ClaudeCode => write!(f, "claude-code"),
58 }
59 }
60}
61
62#[derive(Debug, Default, Serialize, Deserialize)]
65pub struct Config {
66 #[serde(default)]
67 pub ephemeral: EphemeralConfig,
68 #[serde(default)]
69 pub llm: LlmSection,
70 #[serde(default)]
71 pub pipeline: Option<PipelineSection>,
72 #[serde(default)]
73 pub graph: Option<GraphSection>,
74}
75
76#[derive(Debug, Serialize, Deserialize)]
77pub struct EphemeralConfig {
78 #[serde(default = "default_max_entries")]
79 pub max_entries: usize,
80}
81
82impl Default for EphemeralConfig {
83 fn default() -> Self {
84 Self {
85 max_entries: DEFAULT_MAX_ENTRIES,
86 }
87 }
88}
89
90fn default_max_entries() -> usize {
91 DEFAULT_MAX_ENTRIES
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct LlmSection {
96 #[serde(default = "default_provider")]
97 pub provider: Provider,
98 #[serde(default)]
99 pub model: String,
100 #[serde(default)]
101 pub api_base: String,
102}
103
104impl Default for LlmSection {
105 fn default() -> Self {
106 Self {
107 provider: Provider::Anthropic,
108 model: String::new(),
109 api_base: String::new(),
110 }
111 }
112}
113
114impl LlmSection {
115 #[must_use]
117 pub fn resolved_model(&self) -> &str {
118 if self.model.is_empty() {
119 self.provider.default_model()
120 } else {
121 &self.model
122 }
123 }
124
125 #[must_use]
127 pub fn resolved_api_base(&self) -> &str {
128 if self.api_base.is_empty() {
129 self.provider.default_api_base()
130 } else {
131 &self.api_base
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PipelineSection {
138 #[serde(default)]
140 pub docs_dir: Option<String>,
141 #[serde(default)]
143 pub auto_sync: Option<bool>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct GraphSection {
148 #[serde(default = "default_graph_mode")]
150 pub mode: String,
151 #[serde(default = "default_graph_url")]
153 pub url: String,
154 #[serde(default = "default_graph_namespace")]
156 pub namespace: String,
157 #[serde(default)]
159 pub database: String,
160 #[serde(default)]
162 pub username: String,
163 #[serde(default)]
165 pub password_file: String,
166 #[serde(default)]
172 pub scoring: GraphScoringConfig,
173}
174
175impl Default for GraphSection {
176 fn default() -> Self {
177 Self {
178 mode: default_graph_mode(),
179 url: default_graph_url(),
180 namespace: default_graph_namespace(),
181 database: String::new(),
182 username: String::new(),
183 password_file: String::new(),
184 scoring: GraphScoringConfig::default(),
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(default)]
209pub struct GraphScoringConfig {
210 pub weight_semantic: f64,
212 pub weight_hotness: f64,
214 pub weight_utility: f64,
216}
217
218impl Default for GraphScoringConfig {
219 fn default() -> Self {
220 Self {
221 weight_semantic: 0.45,
222 weight_hotness: 0.30,
223 weight_utility: 0.25,
224 }
225 }
226}
227
228fn default_graph_mode() -> String {
229 "embedded".to_string()
230}
231
232fn default_graph_url() -> String {
233 "ws://localhost:8787".to_string()
234}
235
236fn default_graph_namespace() -> String {
237 "nullarc".to_string()
238}
239
240fn default_provider() -> Provider {
241 Provider::Anthropic
242}
243
244#[must_use]
248pub fn config_path(base: &Path) -> std::path::PathBuf {
249 base.join(CONFIG_FILE)
250}
251
252#[must_use]
255pub fn load_from_dir(dir: &Path) -> Config {
256 load(dir)
257}
258
259#[must_use]
262pub fn load(base: &Path) -> Config {
263 let path = config_path(base);
264 if !path.exists() {
265 return Config::default();
266 }
267
268 let content = match fs::read_to_string(&path) {
269 Ok(c) => c,
270 Err(_) => return Config::default(),
271 };
272
273 match toml::from_str(&content) {
274 Ok(cfg) => validate(cfg),
275 Err(_) => Config::default(),
276 }
277}
278
279pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
281 let path = config_path(base);
282 let content = toml::to_string_pretty(config)?;
283 fs::write(&path, content)?;
284 Ok(())
285}
286
287#[must_use]
289pub fn exists(base: &Path) -> bool {
290 config_path(base).exists()
291}
292
293fn validate(mut cfg: Config) -> Config {
294 if !(1..=50).contains(&cfg.ephemeral.max_entries) {
295 cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
296 }
297 cfg
298}
299
300impl Config {
303 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
305 use crate::error::RecallError;
306 match key {
307 "llm.provider" | "provider" => {
308 let provider = Provider::from_str_loose(value)?;
309 self.llm.model = String::new();
311 self.llm.api_base = String::new();
312 self.llm.provider = provider;
313 Ok(())
314 }
315 "llm.model" | "model" => {
316 self.llm.model = value.to_string();
317 Ok(())
318 }
319 "llm.api_base" | "api_base" => {
320 self.llm.api_base = value.to_string();
321 Ok(())
322 }
323 "ephemeral.max_entries" => {
324 let n: usize = value
325 .parse()
326 .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
327 if !(1..=50).contains(&n) {
328 return Err(RecallError::Config(
329 "max_entries must be between 1 and 50".into(),
330 ));
331 }
332 self.ephemeral.max_entries = n;
333 Ok(())
334 }
335 "pipeline.docs_dir" => {
336 let section = self.pipeline.get_or_insert(PipelineSection {
337 docs_dir: None,
338 auto_sync: None,
339 });
340 section.docs_dir = Some(value.to_string());
341 Ok(())
342 }
343 "pipeline.auto_sync" => {
344 let b: bool = value
345 .parse()
346 .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
347 let section = self.pipeline.get_or_insert(PipelineSection {
348 docs_dir: None,
349 auto_sync: None,
350 });
351 section.auto_sync = Some(b);
352 Ok(())
353 }
354 other => Err(RecallError::Config(format!("unknown config key: {other}"))),
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn default_config() {
365 let cfg = Config::default();
366 assert_eq!(cfg.ephemeral.max_entries, 5);
367 assert_eq!(cfg.llm.provider, Provider::Anthropic);
368 assert!(cfg.llm.model.is_empty());
369 }
370
371 #[test]
372 fn parse_ephemeral_only() {
373 let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
374 assert_eq!(cfg.ephemeral.max_entries, 10);
375 assert_eq!(cfg.llm.provider, Provider::Anthropic);
376 }
377
378 #[test]
379 fn parse_llm_section() {
380 let cfg: Config = toml::from_str(
381 "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
382 )
383 .unwrap();
384 assert_eq!(cfg.llm.provider, Provider::Openai);
385 assert_eq!(cfg.llm.model, "llama3.1");
386 assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
387 }
388
389 #[test]
390 fn parse_claude_code_provider() {
391 let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
392 assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
393 }
394
395 #[test]
396 fn resolved_defaults() {
397 let llm = LlmSection::default();
398 assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
399 assert_eq!(
400 llm.resolved_api_base(),
401 "https://api.anthropic.com/v1/messages"
402 );
403 }
404
405 #[test]
406 fn resolved_custom_overrides_default() {
407 let llm = LlmSection {
408 provider: Provider::Openai,
409 model: "mistral-7b".into(),
410 api_base: String::new(),
411 };
412 assert_eq!(llm.resolved_model(), "mistral-7b");
413 assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
414 }
415
416 #[test]
417 fn round_trip_toml() {
418 let cfg = Config {
419 ephemeral: EphemeralConfig { max_entries: 3 },
420 llm: LlmSection {
421 provider: Provider::Openai,
422 model: "llama3.2".into(),
423 api_base: "http://localhost:11434/v1".into(),
424 },
425 pipeline: None,
426 graph: None,
427 };
428 let s = toml::to_string_pretty(&cfg).unwrap();
429 let parsed: Config = toml::from_str(&s).unwrap();
430 assert_eq!(parsed.ephemeral.max_entries, 3);
431 assert_eq!(parsed.llm.provider, Provider::Openai);
432 assert_eq!(parsed.llm.model, "llama3.2");
433 }
434
435 #[test]
436 fn set_key_provider() {
437 let mut cfg = Config::default();
438 cfg.set_key("llm.provider", "ollama").unwrap();
439 assert_eq!(cfg.llm.provider, Provider::Openai);
440 assert!(cfg.llm.model.is_empty());
441 }
442
443 #[test]
444 fn set_key_model() {
445 let mut cfg = Config::default();
446 cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
447 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
448 }
449
450 #[test]
451 fn set_key_unknown_fails() {
452 let mut cfg = Config::default();
453 assert!(cfg.set_key("nonexistent.key", "value").is_err());
454 }
455
456 #[test]
457 fn provider_from_str_loose() {
458 assert_eq!(
459 Provider::from_str_loose("ollama").unwrap(),
460 Provider::Openai
461 );
462 assert_eq!(
463 Provider::from_str_loose("claude").unwrap(),
464 Provider::Anthropic
465 );
466 assert_eq!(
467 Provider::from_str_loose("claude-code").unwrap(),
468 Provider::ClaudeCode
469 );
470 assert!(Provider::from_str_loose("unknown").is_err());
471 }
472
473 #[test]
474 fn save_and_load() {
475 let tmp = tempfile::tempdir().unwrap();
476 let cfg = Config {
477 ephemeral: EphemeralConfig { max_entries: 7 },
478 llm: LlmSection {
479 provider: Provider::ClaudeCode,
480 model: String::new(),
481 api_base: String::new(),
482 },
483 pipeline: None,
484 graph: None,
485 };
486 save(tmp.path(), &cfg).unwrap();
487 let loaded = load(tmp.path());
488 assert_eq!(loaded.ephemeral.max_entries, 7);
489 assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
490 }
491
492 #[test]
493 fn load_nonexistent_file() {
494 let tmp = tempfile::tempdir().unwrap();
495 let cfg = load(tmp.path());
496 assert_eq!(cfg.ephemeral.max_entries, 5);
497 }
498
499 #[test]
500 fn validate_out_of_range() {
501 let cfg = validate(Config {
502 ephemeral: EphemeralConfig { max_entries: 100 },
503 llm: LlmSection::default(),
504 pipeline: None,
505 graph: None,
506 });
507 assert_eq!(cfg.ephemeral.max_entries, 5);
508 }
509
510 #[test]
511 fn graph_scoring_defaults_match_legacy_hardcodes() {
512 let scoring = GraphScoringConfig::default();
513 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
514 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
515 assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
516 }
517
518 #[test]
519 fn graph_scoring_partial_toml_fills_defaults() {
520 let scoring: GraphScoringConfig =
521 toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
522 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
523 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
524 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
525 }
526
527 #[test]
528 fn graph_scoring_empty_section_yields_defaults() {
529 let section: GraphSection = toml::from_str("").expect("parse empty graph section");
530 let defaults = GraphScoringConfig::default();
531 assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
532 assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
533 assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
534 }
535
536 #[test]
537 fn graph_scoring_nested_under_graph() {
538 let cfg: Config = toml::from_str(
539 "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
540 )
541 .expect("parse nested scoring");
542 let scoring = cfg.graph.expect("graph section present").scoring;
543 assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
544 assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
545 assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
546 }
547}