use crate::search::adaptive::AdaptiveConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SemantexConfig {
pub max_count: usize,
pub content: bool,
pub context_lines: usize,
pub rerank: bool,
pub max_file_size: u64,
pub max_file_count: usize,
pub chunk_size: usize,
pub chunk_overlap: usize,
pub rrf_k: f32,
pub plaid_nbits: usize,
pub rerank_candidates: usize,
pub rerank_top_n: usize,
pub model_dir: Option<PathBuf>,
pub adaptive_sizing: bool,
pub adaptive_dedup: bool,
pub adaptive_dedup_gap: f32,
pub min_score_identifier: f32,
pub min_score_keyword: f32,
pub min_score_semantic: f32,
pub min_score_mixed: f32,
pub use_bm25_stemmer: bool,
pub dense_backend: String,
pub embedder: String,
pub reranker_model: String,
pub llm_model: String,
pub hnsw_ef_search: usize,
pub hnsw_preset: String,
pub dense_rescore_k: usize,
}
impl Default for SemantexConfig {
fn default() -> Self {
Self {
max_count: 15,
content: false,
context_lines: 0,
rerank: false,
max_file_size: 1_048_576, max_file_count: 50_000,
chunk_size: 512,
chunk_overlap: 128,
rrf_k: 60.0,
plaid_nbits: 4,
rerank_candidates: 100,
rerank_top_n: 25,
model_dir: None,
adaptive_sizing: true,
adaptive_dedup: true,
adaptive_dedup_gap: 0.10,
min_score_identifier: 0.08,
min_score_keyword: 0.15,
min_score_semantic: 0.10,
min_score_mixed: 0.10,
use_bm25_stemmer: true,
dense_backend: String::new(),
embedder: "lateon-colbert".to_string(),
reranker_model: "bge-reranker-v2-m3".to_string(),
llm_model: String::new(),
hnsw_ef_search: 0, hnsw_preset: "default".to_string(),
dense_rescore_k: 0,
}
}
}
impl SemantexConfig {
pub fn load(project_path: Option<&Path>) -> Result<Self> {
let mut config = Self::default();
let global_config = Self::global_config_path();
if global_config.exists() {
let content = std::fs::read_to_string(&global_config)
.with_context(|| format!("Failed to read {}", global_config.display()))?;
config = serde_yml::from_str(&content)
.with_context(|| format!("Failed to parse {}", global_config.display()))?;
}
if let Some(project) = project_path {
let project_config = project.join(".semantexrc.yaml");
if project_config.exists() {
let content = std::fs::read_to_string(&project_config)
.with_context(|| format!("Failed to read {}", project_config.display()))?;
let project_cfg: SemantexConfig = serde_yml::from_str(&content)
.with_context(|| format!("Failed to parse {}", project_config.display()))?;
config = project_cfg;
}
}
if let Ok(v) = std::env::var("SEMANTEX_MAX_COUNT") {
config.max_count = v.parse().unwrap_or(config.max_count);
}
if let Ok(v) = std::env::var("SEMANTEX_CONTENT") {
config.content = v == "1" || v.to_lowercase() == "true";
}
if let Ok(v) = std::env::var("SEMANTEX_RERANK") {
config.rerank = v != "0" && v.to_lowercase() != "false";
}
if let Ok(v) = std::env::var("SEMANTEX_ADAPTIVE_SIZING") {
config.adaptive_sizing = v != "0" && !v.eq_ignore_ascii_case("false");
}
config.rerank_top_n = env_usize("SEMANTEX_RERANK_CANDIDATES", config.rerank_top_n);
if let Ok(v) = std::env::var("SEMANTEX_MAX_FILE_SIZE") {
config.max_file_size = v.parse().unwrap_or(config.max_file_size);
}
if let Ok(v) = std::env::var("SEMANTEX_MODEL_DIR") {
config.model_dir = Some(PathBuf::from(v));
}
if let Ok(v) = std::env::var("SEMANTEX_DENSE_BACKEND") {
let trimmed = v.trim();
if !trimmed.is_empty() {
config.dense_backend = trimmed.to_string();
}
}
config.embedder = env_string("SEMANTEX_EMBEDDER", &config.embedder);
config.reranker_model = env_string("SEMANTEX_RERANKER_MODEL", &config.reranker_model);
config.llm_model = env_string("SEMANTEX_LLM_MODEL", &config.llm_model);
config.hnsw_ef_search = env_usize("SEMANTEX_HNSW_EF_SEARCH", config.hnsw_ef_search);
config.hnsw_preset = env_string("SEMANTEX_HNSW_PRESET", &config.hnsw_preset);
config.dense_rescore_k = std::env::var("SEMANTEX_DENSE_RESCORE_K")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(config.dense_rescore_k);
Ok(config)
}
pub fn semantex_home() -> PathBuf {
if let Ok(val) = std::env::var("SEMANTEX_HOME") {
return PathBuf::from(val);
}
dirs::home_dir().map_or_else(
|| std::env::temp_dir().join("semantex"),
|h| h.join(".semantex"),
)
}
pub fn models_dir(&self) -> PathBuf {
self.model_dir
.clone()
.unwrap_or_else(|| Self::semantex_home().join("models"))
}
pub fn project_index_dir(project_path: &Path) -> PathBuf {
project_path.join(".semantex")
}
pub fn adaptive_config(&self) -> AdaptiveConfig {
AdaptiveConfig {
enabled: self.adaptive_sizing,
min_score_identifier: self.min_score_identifier,
min_score_keyword: self.min_score_keyword,
min_score_semantic: self.min_score_semantic,
min_score_mixed: self.min_score_mixed,
deduplicate: self.adaptive_dedup,
dedup_score_gap: self.adaptive_dedup_gap,
exhaustive: false, }
}
fn global_config_path() -> PathBuf {
if let Ok(val) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(val).join("semantex").join("config.yaml");
}
dirs::config_dir().map_or_else(
|| Self::semantex_home().join("config.yaml"),
|d| d.join("semantex").join("config.yaml"),
)
}
}
pub(crate) fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(default)
}
pub(crate) fn env_string(key: &str, default: &str) -> String {
std::env::var(key)
.ok()
.map(|v| v.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| default.to_string())
}
pub(crate) fn env_bool(key: &str) -> bool {
std::env::var(key).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
pub(crate) fn env_bool_default_true(key: &str) -> bool {
match std::env::var(key) {
Ok(v) => v != "0" && !v.eq_ignore_ascii_case("false"),
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rrf_k_default_is_60_per_integration_doc_d_rrf_k() {
let cfg = SemantexConfig::default();
assert!(
(cfg.rrf_k - 60.0).abs() < f32::EPSILON,
"rrf_k default = {}",
cfg.rrf_k
);
}
#[test]
fn default_config_enables_bm25_stemmer() {
let cfg = SemantexConfig::default();
assert!(
cfg.use_bm25_stemmer,
"Default must preserve legacy stemming behavior (true) per v0.4 spec §9.2.3"
);
}
#[test]
fn default_dense_backend_alias_is_empty() {
let cfg = SemantexConfig::default();
assert_eq!(
cfg.dense_backend, "",
"deprecated alias default must be empty (canonical embedder decides)"
);
}
#[test]
fn default_selection_fields() {
let cfg = SemantexConfig::default();
assert_eq!(cfg.embedder, "lateon-colbert");
assert_eq!(cfg.reranker_model, "bge-reranker-v2-m3");
assert_eq!(
cfg.llm_model, "",
"no LLM selected by default (zero-LLM-deps build)"
);
}
#[test]
fn dense_tuning_defaults() {
let cfg = SemantexConfig::default();
assert_eq!(
cfg.hnsw_ef_search, 0,
"0 means 'use the preset's ef_search' so SEMANTEX_HNSW_PRESET is honored"
);
assert_eq!(cfg.hnsw_preset, "default");
assert_eq!(cfg.dense_rescore_k, 0, "0 means derive 4×k at query time");
}
#[test]
fn env_string_reads_value_or_default() {
assert_eq!(
env_string("SEMANTEX_DENSE_BACKEND_TEST_UNSET_KEY", "coderank-hnsw"),
"coderank-hnsw"
);
}
#[test]
fn rerank_top_n_default_is_25() {
let cfg = SemantexConfig::default();
assert_eq!(cfg.rerank_top_n, 25);
}
#[test]
fn rerank_candidates_default_is_still_100() {
let cfg = SemantexConfig::default();
assert_eq!(
cfg.rerank_candidates, 100,
"retrieval pool width must remain 100 (default search path unchanged)"
);
}
#[test]
fn adaptive_sizing_default_and_env_overlay() {
let cfg = SemantexConfig::default();
assert!(
cfg.adaptive_sizing,
"adaptive_sizing default must be true (post-fusion cap is on by default)"
);
let _g = crate::search::RERANKER_TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_ADAPTIVE_SIZING").ok();
let assert_load = |val: &str| -> bool {
unsafe { std::env::set_var("SEMANTEX_ADAPTIVE_SIZING", val) };
SemantexConfig::load(None).expect("load").adaptive_sizing
};
assert!(!assert_load("0"), "\"0\" should disable adaptive_sizing");
assert!(
!assert_load("false"),
"\"false\" should disable adaptive_sizing"
);
assert!(
!assert_load("False"),
"\"False\" (mixed-case) should disable adaptive_sizing"
);
assert!(
!assert_load("FALSE"),
"\"FALSE\" should disable adaptive_sizing"
);
assert!(assert_load("1"), "\"1\" should enable adaptive_sizing");
assert!(
assert_load("true"),
"\"true\" should enable adaptive_sizing"
);
assert!(
assert_load("True"),
"\"True\" should enable adaptive_sizing"
);
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_ADAPTIVE_SIZING", v),
None => std::env::remove_var("SEMANTEX_ADAPTIVE_SIZING"),
}
}
}
#[test]
fn semantex_rerank_candidates_env_sets_rerank_top_n() {
let _g = crate::search::RERANKER_TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_RERANK_CANDIDATES").ok();
unsafe {
std::env::set_var("SEMANTEX_RERANK_CANDIDATES", "12");
}
let cfg = SemantexConfig::load(None).expect("load");
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_RERANK_CANDIDATES", v),
None => std::env::remove_var("SEMANTEX_RERANK_CANDIDATES"),
}
}
assert_eq!(cfg.rerank_top_n, 12, "env overlay sets the scoring window");
}
static ENV_BOOL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn env_bool_recognizes_truthy_and_falsy_values() {
const KEY: &str = "SEMANTEX_TEST_ENV_BOOL_PROBE";
let _g = ENV_BOOL_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
unsafe {
std::env::remove_var(KEY);
}
assert!(!env_bool(KEY), "unset must default to false");
for truthy in ["1", "true", "TRUE", "True"] {
unsafe {
std::env::set_var(KEY, truthy);
}
assert!(env_bool(KEY), "{truthy:?} must be truthy");
}
for falsy in ["0", "false", "yes", ""] {
unsafe {
std::env::set_var(KEY, falsy);
}
assert!(!env_bool(KEY), "{falsy:?} must be falsy");
}
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
fn env_bool_default_true_defaults_on_and_opts_out_on_falsy() {
const KEY: &str = "SEMANTEX_TEST_ENV_BOOL_DEFAULT_TRUE_PROBE";
let _g = ENV_BOOL_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
unsafe {
std::env::remove_var(KEY);
}
assert!(env_bool_default_true(KEY), "unset must default to true");
for falsy in ["0", "false", "False", "FALSE"] {
unsafe {
std::env::set_var(KEY, falsy);
}
assert!(
!env_bool_default_true(KEY),
"{falsy:?} must opt OUT (false)"
);
}
for truthy in ["1", "true", "True", "yes", "", "on", "anything"] {
unsafe {
std::env::set_var(KEY, truthy);
}
assert!(env_bool_default_true(KEY), "{truthy:?} must stay ON (true)");
}
unsafe {
std::env::remove_var(KEY);
}
}
}