1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use seshat_core::{DetectionConfig, ScanConfig, ServerConfig};
17use seshat_embedding::EmbeddingConfig;
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default, rename_all = "snake_case")]
25pub struct AppConfig {
26 pub scan: ScanConfig,
28
29 pub detection: DetectionConfig,
31
32 pub server: ServerConfig,
34
35 pub watcher: WatcherConfig,
37
38 pub backup: BackupConfig,
40
41 pub cache: CacheConfig,
43
44 pub embedding: Option<EmbeddingConfig>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default, rename_all = "snake_case")]
52pub struct WatcherConfig {
53 pub enabled: bool,
55 pub debounce_ms: u64,
57 pub ignore_patterns: Vec<String>,
59 pub warm_tier_interval_seconds: u64,
61 pub bulk_change_threshold: usize,
63}
64
65impl Default for WatcherConfig {
66 fn default() -> Self {
67 Self {
68 enabled: true,
69 debounce_ms: 500,
70 ignore_patterns: Vec::new(),
71 warm_tier_interval_seconds: 30,
72 bulk_change_threshold: 20,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default, rename_all = "snake_case")]
80pub struct BackupConfig {
81 pub enabled: bool,
83 pub max_backups: usize,
85 pub backup_dir: String,
88}
89
90impl Default for BackupConfig {
91 fn default() -> Self {
92 Self {
93 enabled: true,
94 max_backups: 5,
95 backup_dir: ".seshat/backups".to_owned(),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(default, rename_all = "snake_case")]
103pub struct CacheConfig {
104 pub enabled: bool,
106 pub max_size_mb: u64,
108 pub ttl_seconds: u64,
110}
111
112impl Default for CacheConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 max_size_mb: 128,
117 ttl_seconds: 3600,
118 }
119 }
120}
121
122const CONFIG_FILENAME: &str = "seshat.toml";
124
125const SESHAT_LOG_ENV: &str = "SESHAT_LOG";
127
128impl AppConfig {
129 pub fn load() -> Result<Self, ConfigError> {
142 let mut config = if let Some(path) = Self::find_config_file() {
143 Self::load_from_file(&path)?
144 } else {
145 Self::default()
146 };
147
148 if let Ok(log_level) = std::env::var(SESHAT_LOG_ENV) {
150 config.server.log_level = log_level;
151 }
152
153 Ok(config)
154 }
155
156 pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
158 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
159 path: path.to_path_buf(),
160 source: e,
161 })?;
162 Self::from_toml_str(&contents)
163 }
164
165 pub fn from_toml_str(s: &str) -> Result<Self, ConfigError> {
167 toml::from_str(s).map_err(|e| ConfigError::Parse {
168 details: e.to_string(),
169 })
170 }
171
172 fn find_config_file() -> Option<PathBuf> {
176 let cwd_path = PathBuf::from(CONFIG_FILENAME);
178 if cwd_path.is_file() {
179 return Some(cwd_path);
180 }
181
182 if let Some(config_dir) = dirs::config_dir() {
184 let xdg_path = config_dir.join("seshat").join(CONFIG_FILENAME);
185 if xdg_path.is_file() {
186 return Some(xdg_path);
187 }
188 }
189
190 None
191 }
192}
193
194#[derive(Debug, thiserror::Error)]
196pub enum ConfigError {
197 #[error("failed to read config file '{path}': {source}")]
199 ReadFile {
200 path: PathBuf,
201 source: std::io::Error,
202 },
203 #[error("failed to parse config: {details}")]
205 Parse { details: String },
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn default_config_is_valid() {
214 let cfg = AppConfig::default();
215 assert!(cfg.scan.exclude_paths.is_empty());
216 assert_eq!(cfg.scan.max_file_size_kb, 512);
217 assert!((cfg.detection.confidence_strong - 0.85).abs() < f64::EPSILON);
218 assert_eq!(cfg.server.log_level, "info");
219 assert!(cfg.watcher.enabled);
220 assert_eq!(cfg.watcher.debounce_ms, 500);
221 assert!(cfg.backup.enabled);
222 assert_eq!(cfg.backup.max_backups, 5);
223 assert!(cfg.cache.enabled);
224 assert_eq!(cfg.cache.max_size_mb, 128);
225 assert!(cfg.embedding.is_none());
226 }
227
228 #[test]
229 fn from_toml_full_config() {
230 let toml_str = r#"
231[scan]
232exclude_patterns = ["*.log", "target/"]
233max_file_size_kb = 1024
234
235[detection]
236confidence_strong = 0.90
237confidence_moderate = 0.60
238confidence_weak = 0.30
239max_snippet_lines = 30
240
241[server]
242log_level = "debug"
243
244[watcher]
245enabled = false
246debounce_ms = 1000
247ignore_patterns = ["*.tmp"]
248
249[backup]
250enabled = false
251max_backups = 10
252backup_dir = "/tmp/seshat-backups"
253
254[cache]
255enabled = false
256max_size_mb = 256
257ttl_seconds = 7200
258
259[embedding]
260model = "all-MiniLM-L6-v2"
261dimension = 384
262batch_size = 64
263"#;
264 let cfg = AppConfig::from_toml_str(toml_str).expect("valid TOML");
265 assert_eq!(cfg.scan.exclude_paths, vec!["*.log", "target/"]);
267 assert_eq!(cfg.scan.max_file_size_kb, 1024);
268 assert!((cfg.detection.confidence_strong - 0.90).abs() < f64::EPSILON);
269 assert!((cfg.detection.confidence_moderate - 0.60).abs() < f64::EPSILON);
270 assert_eq!(cfg.detection.max_snippet_lines, 30);
271 assert_eq!(cfg.server.log_level, "debug");
272 assert!(!cfg.watcher.enabled);
273 assert_eq!(cfg.watcher.debounce_ms, 1000);
274 assert_eq!(cfg.watcher.ignore_patterns, vec!["*.tmp"]);
275 assert!(!cfg.backup.enabled);
276 assert_eq!(cfg.backup.max_backups, 10);
277 assert_eq!(cfg.backup.backup_dir, "/tmp/seshat-backups");
278 assert!(!cfg.cache.enabled);
279 assert_eq!(cfg.cache.max_size_mb, 256);
280 assert_eq!(cfg.cache.ttl_seconds, 7200);
281 let emb = cfg.embedding.expect("embedding section present");
282 assert_eq!(emb.model, "all-MiniLM-L6-v2");
283 assert_eq!(emb.dimension, 384);
284 assert_eq!(emb.batch_size, 64);
285 }
286
287 #[test]
288 fn from_toml_partial_config_merges_defaults() {
289 let toml_str = r#"
290[scan]
291max_file_size_kb = 2048
292
293[server]
294log_level = "warn"
295"#;
296 let cfg = AppConfig::from_toml_str(toml_str).expect("valid TOML");
297 assert_eq!(cfg.scan.max_file_size_kb, 2048);
299 assert_eq!(cfg.server.log_level, "warn");
300 assert!(cfg.scan.exclude_paths.is_empty());
302 assert!((cfg.detection.confidence_strong - 0.85).abs() < f64::EPSILON);
303 assert!(cfg.watcher.enabled);
304 assert_eq!(cfg.watcher.debounce_ms, 500);
305 assert!(cfg.backup.enabled);
306 assert_eq!(cfg.backup.max_backups, 5);
307 assert!(cfg.cache.enabled);
308 assert!(cfg.embedding.is_none());
309 }
310
311 #[test]
312 fn from_toml_empty_string_gives_defaults() {
313 let cfg = AppConfig::from_toml_str("").expect("empty is valid");
314 assert_eq!(cfg.scan.max_file_size_kb, 512);
315 assert_eq!(cfg.server.log_level, "info");
316 assert!(cfg.watcher.enabled);
317 assert!(cfg.embedding.is_none());
318 }
319
320 #[test]
321 fn env_var_overrides_log_level() {
322 let original = std::env::var(SESHAT_LOG_ENV).ok();
324 unsafe { std::env::set_var(SESHAT_LOG_ENV, "trace") };
326
327 let cfg = AppConfig::load().expect("load succeeds");
328 assert_eq!(cfg.server.log_level, "trace");
329
330 match original {
332 Some(val) => unsafe { std::env::set_var(SESHAT_LOG_ENV, val) },
334 None => unsafe { std::env::remove_var(SESHAT_LOG_ENV) },
336 }
337 }
338
339 #[test]
340 fn invalid_toml_returns_parse_error() {
341 let result = AppConfig::from_toml_str("not valid {{{{ toml");
342 assert!(result.is_err());
343 let err = result.unwrap_err();
344 assert!(matches!(err, ConfigError::Parse { .. }));
345 }
346
347 #[test]
348 fn config_serialization_roundtrip() {
349 let cfg = AppConfig::default();
350 let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
351 let roundtripped = AppConfig::from_toml_str(&toml_str).expect("deserialize");
352 assert_eq!(
353 roundtripped.scan.max_file_size_kb,
354 cfg.scan.max_file_size_kb
355 );
356 assert_eq!(roundtripped.server.log_level, cfg.server.log_level);
357 assert_eq!(roundtripped.watcher.debounce_ms, cfg.watcher.debounce_ms);
358 assert_eq!(roundtripped.backup.max_backups, cfg.backup.max_backups);
359 assert_eq!(roundtripped.cache.max_size_mb, cfg.cache.max_size_mb);
360 }
361
362 #[test]
363 fn load_from_nonexistent_file_returns_error() {
364 let result = AppConfig::load_from_file(Path::new("/nonexistent/seshat.toml"));
365 assert!(result.is_err());
366 let err = result.unwrap_err();
367 assert!(matches!(err, ConfigError::ReadFile { .. }));
368 }
369
370 #[test]
371 fn load_from_file_works() {
372 let dir = std::env::temp_dir().join("seshat-config-test");
374 std::fs::create_dir_all(&dir).unwrap();
375 let file_path = dir.join("seshat.toml");
376 std::fs::write(
377 &file_path,
378 r#"
379[server]
380log_level = "error"
381
382[watcher]
383debounce_ms = 2000
384"#,
385 )
386 .unwrap();
387
388 let cfg = AppConfig::load_from_file(&file_path).expect("load from file");
389 assert_eq!(cfg.server.log_level, "error");
390 assert_eq!(cfg.watcher.debounce_ms, 2000);
391 assert!(cfg.watcher.enabled);
393 assert_eq!(cfg.scan.max_file_size_kb, 512);
394
395 let _ = std::fs::remove_dir_all(&dir);
397 }
398}