use std::path::Path;
use tracing::warn;
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default)]
pub struct LoreConfig {
pub reflect: ReflectConfig,
pub store: StoreConfig,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct ReflectConfig {
pub stale_days: u32,
pub dead_entry_days: u32,
pub hot_access_threshold: u32,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default)]
pub struct StoreConfig {
pub similarity_threshold: f64,
}
impl Default for ReflectConfig {
fn default() -> Self {
Self { stale_days: 30, dead_entry_days: 7, hot_access_threshold: 5 }
}
}
impl Default for StoreConfig {
fn default() -> Self {
Self { similarity_threshold: 0.7 }
}
}
impl LoreConfig {
#[must_use]
pub const fn default_toml_content() -> &'static str {
r"# Lorekeeper Configuration
# Defaults are shown. Uncomment and modify to override.
[reflect]
# stale_days = 30
# dead_entry_days = 7
# hot_access_threshold = 5
[store]
# similarity_threshold = 0.7
"
}
#[must_use]
pub fn load(dir: &Path) -> Self {
let config_path = dir.join("config.toml");
if !config_path.exists() {
if let Err(e) = std::fs::write(&config_path, Self::default_toml_content()) {
warn!("Failed to write default config.toml: {e}");
}
return Self::default();
}
let content = match std::fs::read_to_string(&config_path) {
Ok(s) => s,
Err(e) => {
warn!("Failed to read config.toml, using defaults: {e}");
return Self::default();
}
};
match toml::from_str::<Self>(&content) {
Ok(cfg) => cfg,
Err(e) => {
warn!("Failed to parse config.toml, using defaults: {e}");
Self::default()
}
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn load_creates_default_when_missing() {
let dir = tmp();
let cfg = LoreConfig::load(dir.path());
assert!(dir.path().join("config.toml").exists(), "config.toml should be created");
assert_eq!(cfg.reflect.stale_days, 30);
assert_eq!(cfg.reflect.dead_entry_days, 7);
assert_eq!(cfg.reflect.hot_access_threshold, 5);
assert!((cfg.store.similarity_threshold - 0.7).abs() < f64::EPSILON);
}
#[test]
fn load_reads_existing_config() {
let dir = tmp();
let content = "[reflect]\nstale_days = 14\n[store]\nsimilarity_threshold = 0.5\n";
std::fs::write(dir.path().join("config.toml"), content).expect("write");
let cfg = LoreConfig::load(dir.path());
assert_eq!(cfg.reflect.stale_days, 14);
assert!((cfg.store.similarity_threshold - 0.5).abs() < f64::EPSILON);
}
#[test]
fn load_falls_back_to_defaults_on_empty_file() {
let dir = tmp();
std::fs::write(dir.path().join("config.toml"), "").expect("write");
let cfg = LoreConfig::load(dir.path());
assert_eq!(cfg.reflect.stale_days, 30);
}
#[test]
fn load_falls_back_on_invalid_toml() {
let dir = tmp();
std::fs::write(dir.path().join("config.toml"), "this is not toml!!!").expect("write");
let cfg = LoreConfig::load(dir.path());
assert_eq!(cfg.reflect.stale_days, 30);
}
#[test]
fn default_toml_content_is_valid_toml() {
let result = toml::from_str::<LoreConfig>(LoreConfig::default_toml_content());
assert!(result.is_ok(), "Default TOML content should parse cleanly");
}
}