Skip to main content

seshat_cli/
config.rs

1//! # Application Configuration
2//!
3//! Loads and merges configuration from `seshat.toml` with sensible defaults.
4//! Seshat works zero-config out of the box — all config sections have defaults.
5//!
6//! Config file search order:
7//! 1. `./seshat.toml` (current working directory)
8//! 2. `$XDG_CONFIG_HOME/seshat/seshat.toml` (e.g. `~/.config/seshat/seshat.toml`)
9//!
10//! Environment variable overrides:
11//! - `SESHAT_LOG` overrides `server.log_level`
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use seshat_core::{DetectionConfig, ScanConfig, ServerConfig};
17use seshat_embedding::EmbeddingConfig;
18
19/// Top-level application configuration.
20///
21/// All sections use `#[serde(default)]` so that partial TOML files are
22/// merged cleanly with built-in defaults.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default, rename_all = "snake_case")]
25pub struct AppConfig {
26    /// Scanning pipeline settings.
27    pub scan: ScanConfig,
28
29    /// Convention detection thresholds.
30    pub detection: DetectionConfig,
31
32    /// MCP server settings.
33    pub server: ServerConfig,
34
35    /// File-watcher settings.
36    pub watcher: WatcherConfig,
37
38    /// Backup settings.
39    pub backup: BackupConfig,
40
41    /// Cache settings.
42    pub cache: CacheConfig,
43
44    /// Optional embedding / vector search settings.
45    /// `None` when the section is absent from the config file.
46    pub embedding: Option<EmbeddingConfig>,
47}
48
49/// Configuration for the file-watcher subsystem.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default, rename_all = "snake_case")]
52pub struct WatcherConfig {
53    /// Whether the watcher is enabled.
54    pub enabled: bool,
55    /// Debounce delay in milliseconds before processing file events.
56    pub debounce_ms: u64,
57    /// Additional glob patterns to ignore (on top of `scan.exclude_paths`).
58    pub ignore_patterns: Vec<String>,
59    /// Interval in seconds between warm tier convention recalculation runs.
60    pub warm_tier_interval_seconds: u64,
61    /// Number of file changes within a 2-second window that triggers bulk rescan mode.
62    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/// Configuration for database backups.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default, rename_all = "snake_case")]
80pub struct BackupConfig {
81    /// Whether automatic backups are enabled.
82    pub enabled: bool,
83    /// Maximum number of backup copies to retain.
84    pub max_backups: usize,
85    /// Backup directory path. Defaults to `.seshat/backups` relative to the
86    /// project root.
87    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/// Configuration for the IR / query cache.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(default, rename_all = "snake_case")]
103pub struct CacheConfig {
104    /// Whether caching is enabled.
105    pub enabled: bool,
106    /// Maximum cache size in megabytes.
107    pub max_size_mb: u64,
108    /// Time-to-live for cache entries in seconds.
109    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
122/// The config file name Seshat searches for.
123const CONFIG_FILENAME: &str = "seshat.toml";
124
125/// Environment variable that overrides `server.log_level`.
126const SESHAT_LOG_ENV: &str = "SESHAT_LOG";
127
128impl AppConfig {
129    /// Load configuration by searching for `seshat.toml` in standard locations.
130    ///
131    /// Search order:
132    /// 1. Current working directory
133    /// 2. `$XDG_CONFIG_HOME/seshat/` (via [`dirs::config_dir`])
134    ///
135    /// If no config file is found, defaults are returned (zero-config).
136    /// Partial config files are merged with defaults — missing keys use
137    /// their default values.
138    ///
139    /// After file loading, the `SESHAT_LOG` environment variable is checked
140    /// and, if set, overrides `server.log_level`.
141    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        // Environment variable override
149        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    /// Load and parse a specific config file, merging with defaults.
157    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    /// Parse a TOML string into [`AppConfig`], merging with defaults.
166    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    /// Search for `seshat.toml` in the standard locations.
173    ///
174    /// Returns the path to the first config file found, or `None`.
175    fn find_config_file() -> Option<PathBuf> {
176        // 1. Current directory
177        let cwd_path = PathBuf::from(CONFIG_FILENAME);
178        if cwd_path.is_file() {
179            return Some(cwd_path);
180        }
181
182        // 2. XDG config directory
183        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/// Errors that can occur when loading configuration.
195#[derive(Debug, thiserror::Error)]
196pub enum ConfigError {
197    /// Failed to read the config file from disk.
198    #[error("failed to read config file '{path}': {source}")]
199    ReadFile {
200        path: PathBuf,
201        source: std::io::Error,
202    },
203    /// Failed to parse the TOML content.
204    #[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        // old key `exclude_patterns` is accepted via serde alias
266        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        // Overridden values
298        assert_eq!(cfg.scan.max_file_size_kb, 2048);
299        assert_eq!(cfg.server.log_level, "warn");
300        // Defaults for everything else
301        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        // Set env var, load (no file → defaults), check override
323        let original = std::env::var(SESHAT_LOG_ENV).ok();
324        // TODO: Audit that the environment access only happens in single-threaded code.
325        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        // Restore
331        match original {
332            // TODO: Audit that the environment access only happens in single-threaded code.
333            Some(val) => unsafe { std::env::set_var(SESHAT_LOG_ENV, val) },
334            // TODO: Audit that the environment access only happens in single-threaded code.
335            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        // Create a temp file with partial config
373        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        // Defaults for unspecified
392        assert!(cfg.watcher.enabled);
393        assert_eq!(cfg.scan.max_file_size_kb, 512);
394
395        // Cleanup
396        let _ = std::fs::remove_dir_all(&dir);
397    }
398}