use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const LEINDEX_HOME_ENV: &str = "LEINDEX_HOME";
const DEFAULT_MODEL_DIR_SUFFIX: &str = "models";
const DEFAULT_MODEL_NAME: &str = "qwen3-embed-0.6b";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LeIndexConfig {
#[serde(default)]
pub neural: NeuralConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub indexing: IndexingConfig,
#[serde(default)]
pub mcp: McpConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NeuralConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_execution_provider")]
pub execution_provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ort_dylib_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ort_version: Option<String>,
#[serde(default = "default_model_dir")]
pub model_dir: String,
#[serde(default = "default_model_name")]
pub model_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SearchConfig {
#[serde(default = "default_search_mode")]
pub search_mode: String,
#[serde(default = "default_neural_weight")]
pub neural_weight: f64,
#[serde(default)]
pub rerank_enabled: bool,
#[serde(default = "default_rerank_top_n")]
pub rerank_top_n: u32,
#[serde(default)]
pub fragment_index_enabled: bool,
#[serde(default = "default_fragment_max_bytes")]
pub fragment_max_bytes: u64,
#[serde(default = "default_fragment_weight")]
pub fragment_weight: f64,
#[serde(default = "default_true")]
pub fragment_orphan_enabled: bool,
#[serde(default = "default_true")]
pub fragment_naive_fallback: bool,
}
pub fn query_type_for_mode(search_mode: &str) -> Option<crate::search::ranking::QueryType> {
match search_mode {
"text" => Some(crate::search::ranking::QueryType::Text),
"neural" => Some(crate::search::ranking::QueryType::Semantic),
_ => None, }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexingConfig {
#[serde(default = "default_batch_size")]
pub batch_size: u64,
#[serde(default = "default_max_files")]
pub max_files: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpConfig {
#[serde(default = "default_mcp_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_mcp_engine_max_idle_secs")]
pub engine_max_idle_secs: u64,
}
impl Default for NeuralConfig {
fn default() -> Self {
Self {
enabled: false,
execution_provider: default_execution_provider(),
ort_dylib_path: None,
ort_version: None,
model_dir: default_model_dir(),
model_name: default_model_name(),
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
search_mode: default_search_mode(),
neural_weight: default_neural_weight(),
rerank_enabled: false,
rerank_top_n: default_rerank_top_n(),
fragment_index_enabled: false,
fragment_max_bytes: default_fragment_max_bytes(),
fragment_weight: default_fragment_weight(),
fragment_orphan_enabled: default_true(),
fragment_naive_fallback: default_true(),
}
}
}
impl Default for IndexingConfig {
fn default() -> Self {
Self {
batch_size: default_batch_size(),
max_files: default_max_files(),
}
}
}
impl Default for McpConfig {
fn default() -> Self {
Self {
idle_timeout_secs: default_mcp_idle_timeout_secs(),
engine_max_idle_secs: default_mcp_engine_max_idle_secs(),
}
}
}
fn default_execution_provider() -> String {
"auto".to_string()
}
fn default_model_dir() -> String {
resolve_leindex_home()
.map(|h| h.join(DEFAULT_MODEL_DIR_SUFFIX).display().to_string())
.unwrap_or_else(|| format!("~/.leindex/{}", DEFAULT_MODEL_DIR_SUFFIX))
}
fn default_model_name() -> String {
DEFAULT_MODEL_NAME.to_string()
}
fn default_search_mode() -> String {
"hybrid".to_string()
}
fn default_neural_weight() -> f64 {
0.4
}
fn default_fragment_max_bytes() -> u64 {
12_000
}
fn default_fragment_weight() -> f64 {
0.35
}
fn default_true() -> bool {
true
}
fn default_rerank_top_n() -> u32 {
80
}
fn default_batch_size() -> u64 {
500
}
fn default_max_files() -> u64 {
50_000
}
fn default_mcp_idle_timeout_secs() -> u64 {
1800
}
fn default_mcp_engine_max_idle_secs() -> u64 {
600
}
pub fn resolve_leindex_home() -> Option<PathBuf> {
if let Ok(custom) = std::env::var(LEINDEX_HOME_ENV) {
let p = PathBuf::from(custom);
if p.is_absolute() {
return Some(p);
}
}
dirs::home_dir().map(|h| h.join(".leindex"))
}
pub fn config_file_path() -> Option<PathBuf> {
resolve_leindex_home().map(|h| h.join("config").join("leindex.toml"))
}
pub fn model_dir_path() -> Option<PathBuf> {
resolve_leindex_home().map(|h| h.join(DEFAULT_MODEL_DIR_SUFFIX))
}
impl LeIndexConfig {
pub fn save(&self) -> Result<PathBuf, ConfigError> {
let config_path = config_file_path().ok_or(ConfigError::NoHomeDir)?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ConfigError::Io(config_path.clone(), e.to_string()))?;
}
let toml_str =
toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
std::fs::write(&config_path, toml_str)
.map_err(|e| ConfigError::Io(config_path.clone(), e.to_string()))?;
Ok(config_path)
}
pub fn load() -> Result<Self, ConfigError> {
Self::load_from_path(&config_file_path().ok_or(ConfigError::NoHomeDir)?)
}
pub fn neural_weight_f32(&self) -> f32 {
self.search.neural_weight as f32
}
pub fn load_cached() -> &'static LeIndexConfig {
static CACHED: std::sync::OnceLock<LeIndexConfig> = std::sync::OnceLock::new();
CACHED.get_or_init(|| {
Self::load().unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"failed to load leindex.toml for caching; using defaults"
);
LeIndexConfig::default()
})
})
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(path)
.map_err(|e| ConfigError::Io(path.to_path_buf(), e.to_string()))?;
Self::parse_toml(&contents).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
}
fn parse_toml(toml_str: &str) -> Result<Self, String> {
toml::from_str(toml_str).map_err(|e| format!("Failed to parse leindex.toml: {}", e))
}
pub fn load_or_recover() -> Result<(Self, RecoveryAction), ConfigError> {
let config_path = match config_file_path() {
Some(p) => p,
None => return Ok((Self::default(), RecoveryAction::CreatedDefault)),
};
if !config_path.exists() {
return Ok((Self::default(), RecoveryAction::CreatedDefault));
}
let contents = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
return Err(ConfigError::Io(
config_path,
format!("Cannot read config file: {}", e),
));
}
};
match Self::parse_toml(&contents) {
Ok(config) => Ok((config, RecoveryAction::Loaded)),
Err(parse_err) => {
let backup_path = config_path.with_extension("toml.bak");
if let Err(e) = std::fs::rename(&config_path, &backup_path) {
return Err(ConfigError::Io(
config_path,
format!(
"config corrupted ({parse_err}); backup rename to {} failed: {e}",
backup_path.display()
),
));
}
tracing::warn!(
"Config corrupted: {}. Backed up to {}",
parse_err,
backup_path.display()
);
Ok((
Self::default(),
RecoveryAction::RecoveredFromCorrupt(backup_path),
))
}
}
}
}
#[derive(Debug, Clone)]
pub enum RecoveryAction {
Loaded,
CreatedDefault,
RecoveredFromCorrupt(PathBuf),
}
#[derive(Debug, Clone)]
pub enum ConfigError {
NoHomeDir,
Io(PathBuf, String),
Serialize(String),
Parse(PathBuf, String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::NoHomeDir => {
write!(
f,
"Cannot resolve LeIndex home directory. Set LEINDEX_HOME or ensure HOME is set."
)
}
ConfigError::Io(path, msg) => {
write!(f, "I/O error on {}: {}", path.display(), msg)
}
ConfigError::Serialize(msg) => {
write!(f, "Failed to serialize config: {}", msg)
}
ConfigError::Parse(path, msg) => {
write!(f, "Failed to parse {}: {}", path.display(), msg)
}
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_round_trip() {
let config = LeIndexConfig::default();
let toml_str = toml::to_string(&config).unwrap();
let parsed: LeIndexConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_neural_config_schema() {
let config = NeuralConfig {
enabled: true,
execution_provider: "cpu".to_string(),
ort_dylib_path: Some("/usr/local/lib/libonnxruntime.so".to_string()),
ort_version: Some("1.25.0".to_string()),
model_dir: "/home/user/.leindex/models".to_string(),
model_name: "qwen3-embed-0.6b".to_string(),
};
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("enabled = true"));
assert!(toml_str.contains("execution_provider = \"cpu\""));
assert!(toml_str.contains("ort_dylib_path"));
assert!(toml_str.contains("ort_version"));
assert!(toml_str.contains("model_dir"));
}
#[test]
fn test_parse_malformed_returns_error() {
let bad_toml = "[neural\nenabled = true\n";
let result = LeIndexConfig::parse_toml(bad_toml);
assert!(result.is_err());
}
#[test]
fn test_config_roundtrip_preserves_rerank_fields() {
let mut config = LeIndexConfig::default();
config.search.rerank_enabled = true;
config.search.rerank_top_n = 80;
let decoded: LeIndexConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
assert!(decoded.search.rerank_enabled);
assert_eq!(decoded.search.rerank_top_n, 80);
}
#[test]
fn test_default_execution_provider_is_auto() {
assert_eq!(LeIndexConfig::default().neural.execution_provider, "auto");
}
#[test]
fn test_default_neural_weight_is_0_4() {
assert_eq!(LeIndexConfig::default().search.neural_weight, 0.4);
}
#[test]
fn test_config_missing_keys_uses_defaults() {
let toml_str = "[neural]\nenabled = true\n";
let config: LeIndexConfig = toml::from_str(toml_str).unwrap();
assert!(config.neural.enabled);
assert_eq!(config.search.search_mode, "hybrid");
assert_eq!(config.search.neural_weight, 0.4);
assert_eq!(config.indexing.batch_size, 500);
assert_eq!(config.neural.model_name, "qwen3-embed-0.6b");
}
#[test]
fn test_config_empty_uses_defaults() {
let config: LeIndexConfig = toml::from_str("").unwrap();
assert!(!config.neural.enabled);
assert_eq!(config.search.search_mode, "hybrid");
assert_eq!(config.search.neural_weight, 0.4);
}
#[test]
fn test_fragment_config_defaults() {
let config = LeIndexConfig::default();
assert!(!config.search.fragment_index_enabled);
assert_eq!(config.search.fragment_max_bytes, 12_000);
assert_eq!(config.search.fragment_weight, 0.35);
assert!(config.search.fragment_orphan_enabled);
assert!(config.search.fragment_naive_fallback);
let parsed: LeIndexConfig = toml::from_str("").unwrap();
assert_eq!(parsed, config);
}
#[test]
fn test_fragment_config_round_trip() {
let mut config = LeIndexConfig::default();
config.search.fragment_index_enabled = true;
config.search.fragment_max_bytes = 24_000;
config.search.fragment_weight = 0.20;
config.search.fragment_orphan_enabled = false;
config.search.fragment_naive_fallback = false;
let decoded: LeIndexConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
assert_eq!(config, decoded);
}
#[test]
fn test_full_config_round_trip() {
let config = LeIndexConfig {
neural: NeuralConfig {
enabled: true,
execution_provider: "migraphx".to_string(),
ort_dylib_path: Some("/usr/local/lib/libonnxruntime.so.1.25.0".to_string()),
ort_version: Some("1.25.0".to_string()),
model_dir: "/home/user/.leindex/models".to_string(),
model_name: "qwen3-embed-0.6b".to_string(),
},
search: SearchConfig {
search_mode: "hybrid".to_string(),
neural_weight: 0.35,
rerank_enabled: true,
rerank_top_n: 80,
fragment_index_enabled: false,
fragment_max_bytes: 12_000,
fragment_weight: 0.35,
fragment_orphan_enabled: true,
fragment_naive_fallback: true,
},
indexing: IndexingConfig {
batch_size: 1000,
max_files: 100_000,
},
mcp: McpConfig {
idle_timeout_secs: 3600,
engine_max_idle_secs: 1200,
},
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: LeIndexConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_mcp_config_defaults() {
let config = LeIndexConfig::default();
assert_eq!(config.mcp.idle_timeout_secs, 1800);
assert_eq!(config.mcp.engine_max_idle_secs, 600);
let parsed: LeIndexConfig = toml::from_str("").unwrap();
assert_eq!(parsed.mcp, McpConfig::default());
let legacy = "[neural]\nenabled = true\n";
let parsed_legacy: LeIndexConfig = toml::from_str(legacy).unwrap();
assert_eq!(parsed_legacy.mcp, McpConfig::default());
}
#[test]
fn test_mcp_config_round_trip() {
let mut config = LeIndexConfig::default();
config.mcp.idle_timeout_secs = 60;
config.mcp.engine_max_idle_secs = 0;
let decoded: LeIndexConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
assert_eq!(config.mcp, decoded.mcp);
}
#[test]
fn test_ort_dylib_path_skip_serializing_if_none() {
let config = NeuralConfig {
enabled: true,
execution_provider: "cpu".to_string(),
ort_dylib_path: None,
ort_version: None,
model_dir: "/models".to_string(),
model_name: "qwen3-embed-0.6b".to_string(),
};
let toml_str = toml::to_string(&config).unwrap();
assert!(!toml_str.contains("ort_dylib_path"));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_load_or_recover_corrupt_file() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config").join("leindex.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, "[neural\nbroken toml").unwrap();
unsafe { std::env::set_var(LEINDEX_HOME_ENV, tmp.path()) };
let (config, action) = LeIndexConfig::load_or_recover().unwrap();
assert!(matches!(action, RecoveryAction::RecoveredFromCorrupt(_)));
assert!(!config.neural.enabled);
assert!(config_path.with_extension("toml.bak").exists());
unsafe { std::env::remove_var(LEINDEX_HOME_ENV) };
}
#[test]
fn test_config_load_returns_default_when_missing() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var(LEINDEX_HOME_ENV, "/nonexistent/path/for/testing") };
let (config, action) = LeIndexConfig::load_or_recover().unwrap();
assert!(matches!(action, RecoveryAction::CreatedDefault));
assert!(!config.neural.enabled);
unsafe { std::env::remove_var(LEINDEX_HOME_ENV) };
}
#[test]
fn test_resolve_leindex_home_env_override() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var(LEINDEX_HOME_ENV, "/custom/leindex") };
assert_eq!(
resolve_leindex_home(),
Some(PathBuf::from("/custom/leindex"))
);
unsafe { std::env::remove_var(LEINDEX_HOME_ENV) };
}
}