use std::fmt;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub use crate::graph::confidence::ProvenanceWeights;
const DEFAULT_MAX_ENTRIES: usize = 5;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
const CONFIG_FILE: &str = ".recall-echo.toml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Provider {
Anthropic,
Openai,
ClaudeCode,
}
impl Provider {
#[must_use]
pub fn default_model(&self) -> &'static str {
match self {
Provider::Anthropic => "claude-haiku-4-5-20251001",
Provider::Openai => "llama3.2",
Provider::ClaudeCode => "",
}
}
#[must_use]
pub fn default_api_base(&self) -> &'static str {
match self {
Provider::Anthropic => "https://api.anthropic.com/v1/messages",
Provider::Openai => "http://localhost:11434/v1",
Provider::ClaudeCode => "",
}
}
pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
match s.to_lowercase().as_str() {
"anthropic" | "claude" => Ok(Provider::Anthropic),
"openai" | "ollama" => Ok(Provider::Openai),
"claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
other => Err(crate::error::RecallError::Config(format!(
"unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
))),
}
}
}
impl fmt::Display for Provider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Provider::Anthropic => write!(f, "anthropic"),
Provider::Openai => write!(f, "openai"),
Provider::ClaudeCode => write!(f, "claude-code"),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub ephemeral: EphemeralConfig,
#[serde(default)]
pub llm: LlmSection,
#[serde(default)]
pub pipeline: Option<PipelineSection>,
#[serde(default)]
pub graph: Option<GraphSection>,
#[serde(default)]
pub serve: ServeSection,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EphemeralConfig {
#[serde(default = "default_max_entries")]
pub max_entries: usize,
}
impl Default for EphemeralConfig {
fn default() -> Self {
Self {
max_entries: DEFAULT_MAX_ENTRIES,
}
}
}
fn default_max_entries() -> usize {
DEFAULT_MAX_ENTRIES
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LlmSection {
#[serde(default = "default_provider")]
pub provider: Provider,
#[serde(default)]
pub model: String,
#[serde(default)]
pub api_base: String,
}
impl Default for LlmSection {
fn default() -> Self {
Self {
provider: Provider::Anthropic,
model: String::new(),
api_base: String::new(),
}
}
}
impl LlmSection {
#[must_use]
pub fn resolved_model(&self) -> &str {
if self.model.is_empty() {
self.provider.default_model()
} else {
&self.model
}
}
#[must_use]
pub fn resolved_api_base(&self) -> &str {
if self.api_base.is_empty() {
self.provider.default_api_base()
} else {
&self.api_base
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineSection {
#[serde(default)]
pub docs_dir: Option<String>,
#[serde(default)]
pub auto_sync: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphSection {
#[serde(default = "default_graph_mode")]
pub mode: String,
#[serde(default = "default_graph_url")]
pub url: String,
#[serde(default = "default_graph_namespace")]
pub namespace: String,
#[serde(default)]
pub database: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password_file: String,
#[serde(default)]
pub scoring: GraphScoringConfig,
#[serde(default)]
pub provenance: ProvenanceWeights,
}
impl Default for GraphSection {
fn default() -> Self {
Self {
mode: default_graph_mode(),
url: default_graph_url(),
namespace: default_graph_namespace(),
database: String::new(),
username: String::new(),
password_file: String::new(),
scoring: GraphScoringConfig::default(),
provenance: ProvenanceWeights::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServeSection {
pub socket_path: Option<String>,
pub idle_timeout_secs: u64,
}
impl Default for ServeSection {
fn default() -> Self {
Self {
socket_path: None,
idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GraphScoringConfig {
pub weight_semantic: f64,
pub weight_hotness: f64,
pub weight_utility: f64,
}
impl Default for GraphScoringConfig {
fn default() -> Self {
Self {
weight_semantic: 0.45,
weight_hotness: 0.30,
weight_utility: 0.25,
}
}
}
fn default_graph_mode() -> String {
"embedded".to_string()
}
fn default_graph_url() -> String {
"ws://localhost:8787".to_string()
}
fn default_graph_namespace() -> String {
"nullarc".to_string()
}
fn default_provider() -> Provider {
Provider::Anthropic
}
#[must_use]
pub fn config_path(base: &Path) -> std::path::PathBuf {
base.join(CONFIG_FILE)
}
#[must_use]
pub fn load_from_dir(dir: &Path) -> Config {
load(dir)
}
#[must_use]
pub fn load(base: &Path) -> Config {
let path = config_path(base);
if !path.exists() {
return Config::default();
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Config::default(),
};
match toml::from_str(&content) {
Ok(cfg) => validate(cfg),
Err(_) => Config::default(),
}
}
pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
let path = config_path(base);
let content = toml::to_string_pretty(config)?;
fs::write(&path, content)?;
Ok(())
}
#[must_use]
pub fn exists(base: &Path) -> bool {
config_path(base).exists()
}
fn validate(mut cfg: Config) -> Config {
if !(1..=50).contains(&cfg.ephemeral.max_entries) {
cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
}
cfg
}
impl Config {
pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
use crate::error::RecallError;
match key {
"llm.provider" | "provider" => {
let provider = Provider::from_str_loose(value)?;
self.llm.model = String::new();
self.llm.api_base = String::new();
self.llm.provider = provider;
Ok(())
}
"llm.model" | "model" => {
self.llm.model = value.to_string();
Ok(())
}
"llm.api_base" | "api_base" => {
self.llm.api_base = value.to_string();
Ok(())
}
"ephemeral.max_entries" => {
let n: usize = value
.parse()
.map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
if !(1..=50).contains(&n) {
return Err(RecallError::Config(
"max_entries must be between 1 and 50".into(),
));
}
self.ephemeral.max_entries = n;
Ok(())
}
"pipeline.docs_dir" => {
let section = self.pipeline.get_or_insert(PipelineSection {
docs_dir: None,
auto_sync: None,
});
section.docs_dir = Some(value.to_string());
Ok(())
}
"pipeline.auto_sync" => {
let b: bool = value
.parse()
.map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
let section = self.pipeline.get_or_insert(PipelineSection {
docs_dir: None,
auto_sync: None,
});
section.auto_sync = Some(b);
Ok(())
}
"serve.idle_timeout_secs" => {
let secs: u64 = value
.parse()
.map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
self.serve.idle_timeout_secs = secs;
Ok(())
}
"serve.socket_path" => {
self.serve.socket_path = if value.trim().is_empty() {
None
} else {
Some(value.to_string())
};
Ok(())
}
"graph.provenance.weight_external" => {
self.graph_section().provenance.weight_external = parse_weight(value)?;
Ok(())
}
"graph.provenance.weight_user" => {
self.graph_section().provenance.weight_user = parse_weight(value)?;
Ok(())
}
"graph.provenance.weight_self" => {
self.graph_section().provenance.weight_self = parse_weight(value)?;
Ok(())
}
other => Err(RecallError::Config(format!("unknown config key: {other}"))),
}
}
fn graph_section(&mut self) -> &mut GraphSection {
self.graph.get_or_insert_with(GraphSection::default)
}
}
fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
use crate::error::RecallError;
let weight: f64 = value
.parse()
.map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
if !weight.is_finite() || weight < 0.0 {
return Err(RecallError::Config(format!(
"evidence weight must be finite and non-negative, got {value}"
)));
}
Ok(weight)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let cfg = Config::default();
assert_eq!(cfg.ephemeral.max_entries, 5);
assert_eq!(cfg.llm.provider, Provider::Anthropic);
assert!(cfg.llm.model.is_empty());
}
#[test]
fn parse_ephemeral_only() {
let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
assert_eq!(cfg.ephemeral.max_entries, 10);
assert_eq!(cfg.llm.provider, Provider::Anthropic);
}
#[test]
fn graph_mode_defaults_to_embedded() {
let cfg: Config = toml::from_str("[graph]\n").unwrap();
assert_eq!(cfg.graph.unwrap().mode, "embedded");
}
#[test]
fn graph_mode_parses_server() {
let cfg: Config =
toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
let g = cfg.graph.unwrap();
assert_eq!(g.mode, "server");
assert_eq!(g.url, "ws://db.local:8787");
}
#[test]
fn serve_defaults_when_section_absent() {
let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
assert!(cfg.serve.socket_path.is_none());
}
#[test]
fn serve_section_parses_overrides() {
let cfg: Config = toml::from_str(
"[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
)
.unwrap();
assert_eq!(cfg.serve.idle_timeout_secs, 60);
assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
}
#[test]
fn set_key_serve_idle_timeout() {
let mut cfg = Config::default();
cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
assert_eq!(cfg.serve.idle_timeout_secs, 120);
assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
}
#[test]
fn parse_llm_section() {
let cfg: Config = toml::from_str(
"[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
)
.unwrap();
assert_eq!(cfg.llm.provider, Provider::Openai);
assert_eq!(cfg.llm.model, "llama3.1");
assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
}
#[test]
fn parse_claude_code_provider() {
let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
}
#[test]
fn resolved_defaults() {
let llm = LlmSection::default();
assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
assert_eq!(
llm.resolved_api_base(),
"https://api.anthropic.com/v1/messages"
);
}
#[test]
fn resolved_custom_overrides_default() {
let llm = LlmSection {
provider: Provider::Openai,
model: "mistral-7b".into(),
api_base: String::new(),
};
assert_eq!(llm.resolved_model(), "mistral-7b");
assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
}
#[test]
fn round_trip_toml() {
let cfg = Config {
ephemeral: EphemeralConfig { max_entries: 3 },
llm: LlmSection {
provider: Provider::Openai,
model: "llama3.2".into(),
api_base: "http://localhost:11434/v1".into(),
},
pipeline: None,
graph: None,
serve: ServeSection::default(),
};
let s = toml::to_string_pretty(&cfg).unwrap();
let parsed: Config = toml::from_str(&s).unwrap();
assert_eq!(parsed.ephemeral.max_entries, 3);
assert_eq!(parsed.llm.provider, Provider::Openai);
assert_eq!(parsed.llm.model, "llama3.2");
}
#[test]
fn set_key_provider() {
let mut cfg = Config::default();
cfg.set_key("llm.provider", "ollama").unwrap();
assert_eq!(cfg.llm.provider, Provider::Openai);
assert!(cfg.llm.model.is_empty());
}
#[test]
fn set_key_model() {
let mut cfg = Config::default();
cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
}
#[test]
fn set_key_unknown_fails() {
let mut cfg = Config::default();
assert!(cfg.set_key("nonexistent.key", "value").is_err());
}
#[test]
fn provider_from_str_loose() {
assert_eq!(
Provider::from_str_loose("ollama").unwrap(),
Provider::Openai
);
assert_eq!(
Provider::from_str_loose("claude").unwrap(),
Provider::Anthropic
);
assert_eq!(
Provider::from_str_loose("claude-code").unwrap(),
Provider::ClaudeCode
);
assert!(Provider::from_str_loose("unknown").is_err());
}
#[test]
fn save_and_load() {
let tmp = tempfile::tempdir().unwrap();
let cfg = Config {
ephemeral: EphemeralConfig { max_entries: 7 },
llm: LlmSection {
provider: Provider::ClaudeCode,
model: String::new(),
api_base: String::new(),
},
pipeline: None,
graph: None,
serve: ServeSection::default(),
};
save(tmp.path(), &cfg).unwrap();
let loaded = load(tmp.path());
assert_eq!(loaded.ephemeral.max_entries, 7);
assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
}
#[test]
fn load_nonexistent_file() {
let tmp = tempfile::tempdir().unwrap();
let cfg = load(tmp.path());
assert_eq!(cfg.ephemeral.max_entries, 5);
}
#[test]
fn validate_out_of_range() {
let cfg = validate(Config {
ephemeral: EphemeralConfig { max_entries: 100 },
llm: LlmSection::default(),
pipeline: None,
graph: None,
serve: ServeSection::default(),
});
assert_eq!(cfg.ephemeral.max_entries, 5);
}
#[test]
fn graph_scoring_defaults_match_legacy_hardcodes() {
let scoring = GraphScoringConfig::default();
assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
}
#[test]
fn graph_scoring_partial_toml_fills_defaults() {
let scoring: GraphScoringConfig =
toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
}
#[test]
fn graph_scoring_empty_section_yields_defaults() {
let section: GraphSection = toml::from_str("").expect("parse empty graph section");
let defaults = GraphScoringConfig::default();
assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
}
#[test]
fn graph_provenance_defaults_when_section_absent() {
let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
let defaults = ProvenanceWeights::default();
assert_eq!(section.provenance, defaults);
assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
}
#[test]
fn graph_provenance_partial_toml_fills_defaults() {
let cfg: Config =
toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
let provenance = cfg.graph.expect("graph section present").provenance;
assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
}
#[test]
fn set_key_provenance_weights() {
let mut cfg = Config::default();
cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
cfg.set_key("graph.provenance.weight_user", "0").unwrap();
cfg.set_key("graph.provenance.weight_external", "1.5")
.unwrap();
let provenance = cfg
.graph
.as_ref()
.expect("graph section created")
.provenance;
assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
assert!(provenance.weight_user.abs() < f64::EPSILON);
assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
}
#[test]
fn provenance_weights_round_trip_through_toml() {
let mut cfg = Config::default();
cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
let rendered = toml::to_string_pretty(&cfg).expect("render");
let parsed: Config = toml::from_str(&rendered).expect("reparse");
assert_eq!(
parsed.graph.expect("graph section survives").provenance,
ProvenanceWeights::default()
);
}
#[test]
fn graph_scoring_nested_under_graph() {
let cfg: Config = toml::from_str(
"[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
)
.expect("parse nested scoring");
let scoring = cfg.graph.expect("graph section present").scoring;
assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
}
}