Skip to main content

codemem_core/
config.rs

1//! Persistent configuration for Codemem.
2//!
3//! Loads/saves a TOML config at `~/.codemem/config.toml`.
4
5use crate::{CodememError, GraphConfig, ScoringWeights, VectorConfig};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Top-level Codemem configuration.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(default)]
12pub struct CodememConfig {
13    pub scoring: ScoringWeights,
14    pub vector: VectorConfig,
15    pub graph: GraphConfig,
16    pub embedding: EmbeddingConfig,
17    pub storage: StorageConfig,
18}
19
20impl CodememConfig {
21    /// Load configuration from the given path.
22    pub fn load(path: &Path) -> Result<Self, CodememError> {
23        let content = std::fs::read_to_string(path)?;
24        toml::from_str(&content).map_err(|e| CodememError::Config(e.to_string()))
25    }
26
27    /// Save configuration to the given path.
28    pub fn save(&self, path: &Path) -> Result<(), CodememError> {
29        let content =
30            toml::to_string_pretty(self).map_err(|e| CodememError::Config(e.to_string()))?;
31        if let Some(parent) = path.parent() {
32            std::fs::create_dir_all(parent)?;
33        }
34        std::fs::write(path, content)?;
35        Ok(())
36    }
37
38    /// Load from the default path, or return defaults if the file doesn't exist.
39    pub fn load_or_default() -> Self {
40        let path = Self::default_path();
41        if path.exists() {
42            Self::load(&path).unwrap_or_default()
43        } else {
44            Self::default()
45        }
46    }
47
48    /// Default config path: `~/.codemem/config.toml`.
49    pub fn default_path() -> PathBuf {
50        dirs::home_dir()
51            .unwrap_or_else(|| PathBuf::from("."))
52            .join(".codemem")
53            .join("config.toml")
54    }
55}
56
57/// Embedding provider configuration.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(default)]
60pub struct EmbeddingConfig {
61    /// Provider name: "candle" (default), "ollama", or "openai".
62    pub provider: String,
63    /// Model name (provider-specific).
64    pub model: String,
65    /// API URL for remote providers.
66    pub url: String,
67    /// Embedding dimensions.
68    pub dimensions: usize,
69    /// LRU cache capacity.
70    pub cache_capacity: usize,
71}
72
73impl Default for EmbeddingConfig {
74    fn default() -> Self {
75        Self {
76            provider: "candle".to_string(),
77            model: "BAAI/bge-base-en-v1.5".to_string(),
78            url: String::new(),
79            dimensions: 768,
80            cache_capacity: 10_000,
81        }
82    }
83}
84
85/// Storage configuration.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(default)]
88pub struct StorageConfig {
89    /// Path to the database file.
90    pub db_path: String,
91    /// SQLite cache size in MB.
92    pub cache_size_mb: u32,
93    /// SQLite busy timeout in seconds.
94    pub busy_timeout_secs: u64,
95}
96
97impl Default for StorageConfig {
98    fn default() -> Self {
99        Self {
100            db_path: dirs::home_dir()
101                .unwrap_or_else(|| PathBuf::from("."))
102                .join(".codemem")
103                .join("codemem.db")
104                .to_string_lossy()
105                .into_owned(),
106            cache_size_mb: 64,
107            busy_timeout_secs: 5,
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn default_config_roundtrips_through_toml() {
118        let config = CodememConfig::default();
119        let toml_str =
120            toml::to_string_pretty(&config).expect("default config should serialize to TOML");
121        let parsed: CodememConfig =
122            toml::from_str(&toml_str).expect("serialized TOML should parse back");
123        assert!((parsed.scoring.vector_similarity - 0.25).abs() < f64::EPSILON);
124        assert_eq!(parsed.vector.dimensions, 768);
125        assert_eq!(parsed.embedding.provider, "candle");
126    }
127
128    #[test]
129    fn load_nonexistent_returns_error() {
130        let result = CodememConfig::load(Path::new("/tmp/nonexistent_codemem_config.toml"));
131        assert!(result.is_err());
132    }
133
134    #[test]
135    fn save_and_load_roundtrip() {
136        let dir = std::env::temp_dir().join("codemem_config_test");
137        let _ = std::fs::remove_dir_all(&dir);
138        let path = dir.join("config.toml");
139
140        let mut config = CodememConfig::default();
141        config.scoring.vector_similarity = 0.5;
142        config.storage.cache_size_mb = 128;
143
144        config.save(&path).expect("save should succeed");
145        let loaded = CodememConfig::load(&path).expect("load should succeed");
146
147        assert!((loaded.scoring.vector_similarity - 0.5).abs() < f64::EPSILON);
148        assert_eq!(loaded.storage.cache_size_mb, 128);
149
150        let _ = std::fs::remove_dir_all(&dir);
151    }
152
153    #[test]
154    fn load_or_default_returns_default_when_no_file() {
155        let config = CodememConfig::load_or_default();
156        assert!((config.scoring.vector_similarity - 0.25).abs() < f64::EPSILON);
157    }
158
159    #[test]
160    fn default_path_ends_with_config_toml() {
161        let path = CodememConfig::default_path();
162        assert!(path.ends_with("config.toml"));
163    }
164
165    #[test]
166    fn partial_toml_uses_defaults_for_missing_fields() {
167        let partial = r#"
168[scoring]
169vector_similarity = 0.4
170"#;
171        let config: CodememConfig = toml::from_str(partial).expect("partial TOML should parse");
172        assert!((config.scoring.vector_similarity - 0.4).abs() < f64::EPSILON);
173        // Other fields should use defaults
174        assert_eq!(config.vector.dimensions, 768);
175        assert_eq!(config.embedding.provider, "candle");
176    }
177}