Skip to main content

recall_echo/
config.rs

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// ── Provider enum ────────────────────────────────────────────────────────
11
12/// LLM provider for entity extraction.
13#[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// ── Config structs ───────────────────────────────────────────────────────
63
64#[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    /// Resolved model — uses configured value or provider default.
116    #[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    /// Resolved API base — uses configured value or provider default.
126    #[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    /// Directory containing pipeline documents (LEARNING.md, THOUGHTS.md, etc.)
139    #[serde(default)]
140    pub docs_dir: Option<String>,
141    /// Auto-sync pipeline on archive (default: false)
142    #[serde(default)]
143    pub auto_sync: Option<bool>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct GraphSection {
148    /// Connection mode: "embedded" or "server"
149    #[serde(default = "default_graph_mode")]
150    pub mode: String,
151    /// SurrealDB server URL (server mode only)
152    #[serde(default = "default_graph_url")]
153    pub url: String,
154    /// SurrealDB namespace
155    #[serde(default = "default_graph_namespace")]
156    pub namespace: String,
157    /// SurrealDB database name (typically the entity name)
158    #[serde(default)]
159    pub database: String,
160    /// SurrealDB username (typically the entity name)
161    #[serde(default)]
162    pub username: String,
163    /// Path to file containing the database password
164    #[serde(default)]
165    pub password_file: String,
166    /// Scoring weights for utility-weighted semantic search.
167    ///
168    /// Maps to the `[graph.scoring]` section of `.recall-echo.toml`. When
169    /// absent, defaults preserve the original hard-coded weights
170    /// (0.45 / 0.30 / 0.25). See `GraphScoringConfig` for details.
171    #[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/// Scoring weights for utility-weighted semantic search.
190///
191/// The final score for a retrieved entity is computed as a linear combination
192/// of three signals:
193///
194/// ```text
195/// score = weight_semantic * similarity
196///       + weight_hotness  * hotness
197///       + weight_utility  * utility_score
198/// ```
199///
200/// Defaults (`0.45 / 0.30 / 0.25`) match the original hard-coded values, so
201/// omitting the `[graph.scoring]` section from `.recall-echo.toml` produces
202/// identical behavior to pre-v3.9.0 recall-echo.
203///
204/// Weights are not constrained to sum to 1.0 — the scoring function does not
205/// normalize. Callers that change these should calibrate against their own
206/// retrieval outcomes; see `utility-feedback-loop-spec.md` in pulse-null.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(default)]
209pub struct GraphScoringConfig {
210    /// Weight applied to cosine similarity. Default `0.45`.
211    pub weight_semantic: f64,
212    /// Weight applied to the recency/access hotness signal. Default `0.30`.
213    pub weight_hotness: f64,
214    /// Weight applied to the utility score (outcome-feedback EMA). Default `0.25`.
215    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// ── Load / Save ──────────────────────────────────────────────────────────
245
246/// Config file path for a given base directory.
247#[must_use]
248pub fn config_path(base: &Path) -> std::path::PathBuf {
249    base.join(CONFIG_FILE)
250}
251
252/// Load config from .recall-echo.toml in the given directory.
253/// Returns defaults if file doesn't exist or is malformed.
254#[must_use]
255pub fn load_from_dir(dir: &Path) -> Config {
256    load(dir)
257}
258
259/// Load config from .recall-echo.toml in the base dir.
260/// Returns defaults if file doesn't exist or is malformed.
261#[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
279/// Save config to .recall-echo.toml in the base dir.
280pub 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/// Returns true if .recall-echo.toml exists in the directory.
288#[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
300// ── Config mutation helpers ──────────────────────────────────────────────
301
302impl Config {
303    /// Set a dotted config key (e.g. "llm.provider", "ephemeral.max_entries").
304    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                // When switching provider, reset model and api_base to defaults
310                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}