mod env_parser;
mod loader;
mod overrides;
mod paths;
mod validation;
#[cfg(test)]
mod tests_utils;
#[cfg(test)]
use tests_utils::{ENV_MUTEX, cleanup_env_vars};
use crate::embedding::EMBED_MODEL_ID;
use crate::errors::Error;
use serde::Deserialize;
use std::path::PathBuf;
pub use loader::ConfigFile;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub database_path: PathBuf,
#[serde(default)]
pub embedding_model: String,
#[serde(default)]
pub similarity_threshold: f64,
#[serde(default)]
pub recency_weight: f64,
#[serde(default)]
pub hybrid: bool,
#[serde(default = "default_decay_refresh_days")]
pub decay_refresh_days: f64,
#[serde(default = "default_promotion_threshold")]
pub promotion_threshold: i64,
#[serde(default = "default_prune_retrieval_limit")]
pub prune_retrieval_limit: i64,
#[serde(default = "default_prune_min_age_days")]
pub prune_min_age_days: f64,
}
fn default_decay_refresh_days() -> f64 {
30.0
}
fn default_promotion_threshold() -> i64 {
5
}
fn default_prune_retrieval_limit() -> i64 {
5
}
fn default_prune_min_age_days() -> f64 {
30.0
}
impl Default for Config {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
});
let vipune_dir = home.join(".vipune");
Self {
database_path: vipune_dir.join("memories.db"),
embedding_model: EMBED_MODEL_ID.to_string(),
similarity_threshold: 0.85,
recency_weight: 0.3,
hybrid: false,
decay_refresh_days: 30.0,
promotion_threshold: 5,
prune_retrieval_limit: 5,
prune_min_age_days: 30.0,
}
}
}
impl Config {
pub fn load() -> Result<Self, Error> {
let file_config = loader::load_from_file()?;
let mut config = Config::default();
if let Some(mut file) = file_config {
paths::expand_tilde(&mut file.database_path);
config.merge_from_file(file);
}
overrides::apply_env_overrides(&mut config)?;
config.validate()?;
Ok(config)
}
fn merge_from_file(&mut self, file: ConfigFile) {
if !file.database_path.as_os_str().is_empty() {
self.database_path = file.database_path;
}
if !file.embedding_model.is_empty() {
self.embedding_model = file.embedding_model;
}
self.similarity_threshold = file.similarity_threshold;
self.recency_weight = file.recency_weight;
self.decay_refresh_days = file.decay_refresh_days;
self.promotion_threshold = file.promotion_threshold;
self.prune_retrieval_limit = file.prune_retrieval_limit;
self.prune_min_age_days = file.prune_min_age_days;
}
fn validate(&self) -> Result<(), Error> {
let validator = validation::ConfigValidator {
database_path: self.database_path.clone(),
embedding_model: self.embedding_model.clone(),
similarity_threshold: self.similarity_threshold,
recency_weight: self.recency_weight,
decay_refresh_days: self.decay_refresh_days,
promotion_threshold: self.promotion_threshold,
prune_retrieval_limit: self.prune_retrieval_limit,
prune_min_age_days: self.prune_min_age_days,
};
validator.validate()
}
pub fn ensure_directories(&self) -> Result<(), Error> {
if let Some(parent) = self.database_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::Config(format!(
"Failed to create database directory {}: {e}",
parent.display()
))
})?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.database_path.ends_with(".vipune/memories.db"));
assert_eq!(config.embedding_model, "BAAI/bge-small-en-v1.5");
assert_eq!(config.similarity_threshold, 0.85);
assert_eq!(config.recency_weight, 0.3);
assert!(!config.hybrid);
assert_eq!(config.decay_refresh_days, 30.0);
assert_eq!(config.promotion_threshold, 5);
assert_eq!(config.prune_retrieval_limit, 5);
assert_eq!(config.prune_min_age_days, 30.0);
}
#[test]
fn test_config_load_without_file() {
let _guard = ENV_MUTEX.lock().unwrap();
cleanup_env_vars(&[
"VIPUNE_DATABASE_PATH",
"VIPUNE_EMBEDDING_MODEL",
"VIPUNE_SIMILARITY_THRESHOLD",
"VIPUNE_RECENCY_WEIGHT",
]);
let config = Config::load().unwrap();
assert!(config.database_path.ends_with(".vipune/memories.db"));
assert_eq!(config.embedding_model, "BAAI/bge-small-en-v1.5");
assert_eq!(config.similarity_threshold, 0.85);
}
#[test]
fn test_config_file_overrides_defaults() {
let _guard = ENV_MUTEX.lock().unwrap();
cleanup_env_vars(&[
"VIPUNE_DATABASE_PATH",
"VIPUNE_EMBEDDING_MODEL",
"VIPUNE_SIMILARITY_THRESHOLD",
"VIPUNE_RECENCY_WEIGHT",
]);
let config = Config::load().unwrap();
assert!(config.database_path.ends_with(".vipune/memories.db"));
assert_eq!(config.embedding_model, "BAAI/bge-small-en-v1.5");
assert_eq!(config.similarity_threshold, 0.85);
}
#[test]
fn test_default_model_matches_embed_constant() {
let config = Config::default();
assert_eq!(
config.embedding_model,
crate::embedding::EMBED_MODEL_ID,
"Config::default() model must match EMBED_MODEL_ID — update both when changing the default model"
);
}
}