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-dynamic-uint8";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LeIndexConfig {
#[serde(default = "default_legacy_neural_config")]
pub neural: NeuralConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default = "default_legacy_indexing_config")]
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,
#[serde(default = "default_legacy_embedding_config")]
pub embeddings: EmbeddingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EmbeddingConfig {
#[serde(default = "default_enable_hybrid")]
pub enable_hybrid: bool,
#[serde(default = "default_remote_provider")]
pub remote_provider: String,
#[serde(default)]
pub remote_endpoint: Option<String>,
#[serde(default)]
pub remote_api_key: Option<String>,
#[serde(default)]
pub remote_model: Option<String>,
#[serde(default)]
pub remote_dimension: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_doc_template: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_query_template: Option<String>,
#[serde(default)]
pub onnx_model_path: Option<String>,
}
pub const EMBEDDINGGEMMA2_DEFAULT_ENDPOINT: &str = "http://127.0.0.1:8080/v1";
pub const EMBEDDINGGEMMA2_MODEL: &str = "embeddinggemma-2";
pub const EMBEDDINGGEMMA2_DIMENSION: usize = 768;
pub const EMBEDDINGGEMMA2_DOC_TEMPLATE: &str = "title: none | text: {text}";
pub const EMBEDDINGGEMMA2_QUERY_TEMPLATE: &str = "task: code retrieval | query: {text}";
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedRemote {
pub endpoint: String,
pub model: Option<String>,
pub remote_dimension: Option<usize>,
pub doc_template: Option<String>,
pub query_template: Option<String>,
}
impl EmbeddingConfig {
pub fn remote_preset_label(&self) -> &str {
if self.embeddinggemma2_preset_active() {
"embeddinggemma-2"
} else {
"custom"
}
}
fn embeddinggemma2_preset_active(&self) -> bool {
matches!(
self.remote_provider.trim().to_ascii_lowercase().as_str(),
"embeddinggemma-2" | "embeddinggemma2"
)
}
pub fn resolved_remote(&self) -> Option<ResolvedRemote> {
if !self.enable_hybrid {
return None;
}
let preset = self.embeddinggemma2_preset_active();
let endpoint = match self.remote_endpoint.as_deref().map(str::trim) {
Some(e) if !e.is_empty() => Some(e.to_string()),
_ => preset.then(|| EMBEDDINGGEMMA2_DEFAULT_ENDPOINT.to_string()),
}?;
let fill = |value: &Option<String>, preset_value: &str| match value {
Some(v) if !v.trim().is_empty() => Some(v.clone()),
_ if preset => Some(preset_value.to_string()),
_ => None,
};
Some(ResolvedRemote {
endpoint,
model: fill(&self.remote_model, EMBEDDINGGEMMA2_MODEL),
doc_template: fill(&self.remote_doc_template, EMBEDDINGGEMMA2_DOC_TEMPLATE),
query_template: fill(&self.remote_query_template, EMBEDDINGGEMMA2_QUERY_TEMPLATE),
remote_dimension: self
.remote_dimension
.or_else(|| preset.then_some(EMBEDDINGGEMMA2_DIMENSION)),
})
}
pub fn remote_endpoint_configured(&self) -> bool {
self.resolved_remote().is_some()
}
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
enable_hybrid: true,
remote_provider: "embeddinggemma-2".to_string(),
remote_endpoint: None,
remote_api_key: None,
remote_model: None,
remote_dimension: None,
remote_doc_template: None,
remote_query_template: None,
onnx_model_path: None,
}
}
}
fn default_legacy_embedding_config() -> EmbeddingConfig {
EmbeddingConfig {
enable_hybrid: default_enable_hybrid(),
remote_provider: default_remote_provider(),
remote_endpoint: None,
remote_api_key: None,
remote_model: None,
remote_dimension: None,
remote_doc_template: None,
remote_query_template: None,
onnx_model_path: None,
}
}
const fn default_enable_hybrid() -> bool {
false
}
fn default_remote_provider() -> String {
"custom".to_string()
}
#[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,
#[serde(default = "default_mcp_prewarm")]
pub prewarm: String,
}
fn default_legacy_neural_config() -> NeuralConfig {
NeuralConfig {
enabled: false,
..NeuralConfig::default()
}
}
impl Default for NeuralConfig {
fn default() -> Self {
Self {
enabled: true,
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(),
embeddings: EmbeddingConfig::default(),
}
}
}
fn default_legacy_indexing_config() -> IndexingConfig {
IndexingConfig {
batch_size: default_batch_size(),
max_files: default_max_files(),
embeddings: default_legacy_embedding_config(),
}
}
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(),
prewarm: default_mcp_prewarm(),
}
}
}
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_prewarm() -> String {
"full".to_string()
}
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; preserving legacy disabled defaults"
);
Self {
neural: default_legacy_neural_config(),
indexing: default_legacy_indexing_config(),
..Self::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 {
neural: default_legacy_neural_config(),
indexing: default_legacy_indexing_config(),
..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_fresh_neural_default_and_legacy_toml_boundary() {
let fresh = LeIndexConfig::default();
assert!(fresh.neural.enabled);
assert!(fresh.indexing.embeddings.enable_hybrid);
assert_eq!(
fresh.indexing.embeddings.remote_preset_label(),
"embeddinggemma-2"
);
for existing in [
"",
"[neural]\nexecution_provider = \"cpu\"\n",
"[neural]\nenabled = false\n",
] {
let parsed: LeIndexConfig = toml::from_str(existing).expect("existing TOML");
assert!(!parsed.neural.enabled);
}
let explicit: LeIndexConfig =
toml::from_str("[neural]\nenabled = true\n").expect("opt-in TOML");
assert!(explicit.neural.enabled);
}
#[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-dynamic-uint8".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-dynamic-uint8");
}
#[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.search, config.search);
assert!(!parsed.indexing.embeddings.enable_hybrid);
assert_eq!(parsed.indexing.embeddings.remote_preset_label(), "custom");
assert!(config.indexing.embeddings.enable_hybrid);
assert_eq!(
config.indexing.embeddings.remote_preset_label(),
"embeddinggemma-2"
);
}
#[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,
embeddings: EmbeddingConfig::default(),
},
mcp: McpConfig {
idle_timeout_secs: 3600,
engine_max_idle_secs: 1200,
prewarm: "graph".to_string(),
},
};
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);
assert_eq!(config.mcp.prewarm, "full");
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) };
}
}