use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use seshat_core::{DetectionConfig, ScanConfig, ServerConfig};
use seshat_embedding::EmbeddingConfig;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
pub struct AppConfig {
pub scan: ScanConfig,
pub detection: DetectionConfig,
pub server: ServerConfig,
pub watcher: WatcherConfig,
pub backup: BackupConfig,
pub cache: CacheConfig,
pub embedding: Option<EmbeddingConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
pub struct WatcherConfig {
pub enabled: bool,
pub debounce_ms: u64,
pub ignore_patterns: Vec<String>,
pub warm_tier_interval_seconds: u64,
pub bulk_change_threshold: usize,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
enabled: true,
debounce_ms: 500,
ignore_patterns: Vec::new(),
warm_tier_interval_seconds: 30,
bulk_change_threshold: 20,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
pub struct BackupConfig {
pub enabled: bool,
pub max_backups: usize,
pub backup_dir: String,
}
impl Default for BackupConfig {
fn default() -> Self {
Self {
enabled: true,
max_backups: 5,
backup_dir: ".seshat/backups".to_owned(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
pub struct CacheConfig {
pub enabled: bool,
pub max_size_mb: u64,
pub ttl_seconds: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_size_mb: 128,
ttl_seconds: 3600,
}
}
}
const CONFIG_FILENAME: &str = "seshat.toml";
const SESHAT_LOG_ENV: &str = "SESHAT_LOG";
impl AppConfig {
pub fn load() -> Result<Self, ConfigError> {
let mut config = if let Some(path) = Self::find_config_file() {
Self::load_from_file(&path)?
} else {
Self::default()
};
if let Ok(log_level) = std::env::var(SESHAT_LOG_ENV) {
config.server.log_level = log_level;
}
Ok(config)
}
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
Self::from_toml_str(&contents)
}
pub fn from_toml_str(s: &str) -> Result<Self, ConfigError> {
toml::from_str(s).map_err(|e| ConfigError::Parse {
details: e.to_string(),
})
}
fn find_config_file() -> Option<PathBuf> {
let cwd_path = PathBuf::from(CONFIG_FILENAME);
if cwd_path.is_file() {
return Some(cwd_path);
}
if let Some(config_dir) = dirs::config_dir() {
let xdg_path = config_dir.join("seshat").join(CONFIG_FILENAME);
if xdg_path.is_file() {
return Some(xdg_path);
}
}
None
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file '{path}': {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config: {details}")]
Parse { details: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
let cfg = AppConfig::default();
assert!(cfg.scan.exclude_paths.is_empty());
assert_eq!(cfg.scan.max_file_size_kb, 512);
assert!((cfg.detection.confidence_strong - 0.85).abs() < f64::EPSILON);
assert_eq!(cfg.server.log_level, "info");
assert!(cfg.watcher.enabled);
assert_eq!(cfg.watcher.debounce_ms, 500);
assert!(cfg.backup.enabled);
assert_eq!(cfg.backup.max_backups, 5);
assert!(cfg.cache.enabled);
assert_eq!(cfg.cache.max_size_mb, 128);
assert!(cfg.embedding.is_none());
}
#[test]
fn from_toml_full_config() {
let toml_str = r#"
[scan]
exclude_patterns = ["*.log", "target/"]
max_file_size_kb = 1024
[detection]
confidence_strong = 0.90
confidence_moderate = 0.60
confidence_weak = 0.30
max_snippet_lines = 30
[server]
log_level = "debug"
[watcher]
enabled = false
debounce_ms = 1000
ignore_patterns = ["*.tmp"]
[backup]
enabled = false
max_backups = 10
backup_dir = "/tmp/seshat-backups"
[cache]
enabled = false
max_size_mb = 256
ttl_seconds = 7200
[embedding]
model = "all-MiniLM-L6-v2"
dimension = 384
batch_size = 64
"#;
let cfg = AppConfig::from_toml_str(toml_str).expect("valid TOML");
assert_eq!(cfg.scan.exclude_paths, vec!["*.log", "target/"]);
assert_eq!(cfg.scan.max_file_size_kb, 1024);
assert!((cfg.detection.confidence_strong - 0.90).abs() < f64::EPSILON);
assert!((cfg.detection.confidence_moderate - 0.60).abs() < f64::EPSILON);
assert_eq!(cfg.detection.max_snippet_lines, 30);
assert_eq!(cfg.server.log_level, "debug");
assert!(!cfg.watcher.enabled);
assert_eq!(cfg.watcher.debounce_ms, 1000);
assert_eq!(cfg.watcher.ignore_patterns, vec!["*.tmp"]);
assert!(!cfg.backup.enabled);
assert_eq!(cfg.backup.max_backups, 10);
assert_eq!(cfg.backup.backup_dir, "/tmp/seshat-backups");
assert!(!cfg.cache.enabled);
assert_eq!(cfg.cache.max_size_mb, 256);
assert_eq!(cfg.cache.ttl_seconds, 7200);
let emb = cfg.embedding.expect("embedding section present");
assert_eq!(emb.model, "all-MiniLM-L6-v2");
assert_eq!(emb.dimension, 384);
assert_eq!(emb.batch_size, 64);
}
#[test]
fn from_toml_partial_config_merges_defaults() {
let toml_str = r#"
[scan]
max_file_size_kb = 2048
[server]
log_level = "warn"
"#;
let cfg = AppConfig::from_toml_str(toml_str).expect("valid TOML");
assert_eq!(cfg.scan.max_file_size_kb, 2048);
assert_eq!(cfg.server.log_level, "warn");
assert!(cfg.scan.exclude_paths.is_empty());
assert!((cfg.detection.confidence_strong - 0.85).abs() < f64::EPSILON);
assert!(cfg.watcher.enabled);
assert_eq!(cfg.watcher.debounce_ms, 500);
assert!(cfg.backup.enabled);
assert_eq!(cfg.backup.max_backups, 5);
assert!(cfg.cache.enabled);
assert!(cfg.embedding.is_none());
}
#[test]
fn from_toml_empty_string_gives_defaults() {
let cfg = AppConfig::from_toml_str("").expect("empty is valid");
assert_eq!(cfg.scan.max_file_size_kb, 512);
assert_eq!(cfg.server.log_level, "info");
assert!(cfg.watcher.enabled);
assert!(cfg.embedding.is_none());
}
#[test]
fn env_var_overrides_log_level() {
let original = std::env::var(SESHAT_LOG_ENV).ok();
unsafe { std::env::set_var(SESHAT_LOG_ENV, "trace") };
let cfg = AppConfig::load().expect("load succeeds");
assert_eq!(cfg.server.log_level, "trace");
match original {
Some(val) => unsafe { std::env::set_var(SESHAT_LOG_ENV, val) },
None => unsafe { std::env::remove_var(SESHAT_LOG_ENV) },
}
}
#[test]
fn invalid_toml_returns_parse_error() {
let result = AppConfig::from_toml_str("not valid {{{{ toml");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn config_serialization_roundtrip() {
let cfg = AppConfig::default();
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
let roundtripped = AppConfig::from_toml_str(&toml_str).expect("deserialize");
assert_eq!(
roundtripped.scan.max_file_size_kb,
cfg.scan.max_file_size_kb
);
assert_eq!(roundtripped.server.log_level, cfg.server.log_level);
assert_eq!(roundtripped.watcher.debounce_ms, cfg.watcher.debounce_ms);
assert_eq!(roundtripped.backup.max_backups, cfg.backup.max_backups);
assert_eq!(roundtripped.cache.max_size_mb, cfg.cache.max_size_mb);
}
#[test]
fn load_from_nonexistent_file_returns_error() {
let result = AppConfig::load_from_file(Path::new("/nonexistent/seshat.toml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::ReadFile { .. }));
}
#[test]
fn load_from_file_works() {
let dir = std::env::temp_dir().join("seshat-config-test");
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join("seshat.toml");
std::fs::write(
&file_path,
r#"
[server]
log_level = "error"
[watcher]
debounce_ms = 2000
"#,
)
.unwrap();
let cfg = AppConfig::load_from_file(&file_path).expect("load from file");
assert_eq!(cfg.server.log_level, "error");
assert_eq!(cfg.watcher.debounce_ms, 2000);
assert!(cfg.watcher.enabled);
assert_eq!(cfg.scan.max_file_size_kb, 512);
let _ = std::fs::remove_dir_all(&dir);
}
}