use crate::config::SemantexConfig;
use crate::types::IndexMeta;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexState {
NotIndexed,
Building,
Stale,
Ready,
}
pub fn detect(project_path: &Path) -> IndexState {
let semantex_dir = project_path.join(".semantex");
let meta_path = semantex_dir.join("meta.json");
if !meta_path.exists() {
let lock_path = semantex_dir.join(".semantex.lock");
if is_locked(&lock_path) {
return IndexState::Building;
}
return IndexState::NotIndexed;
}
if is_stale(&meta_path) {
return IndexState::Stale;
}
let lock_path = semantex_dir.join(".semantex.lock");
if is_locked(&lock_path) {
return IndexState::Building;
}
IndexState::Ready
}
pub fn detect_for_config(project_path: &Path, config: &SemantexConfig) -> IndexState {
let base = detect(project_path);
if base != IndexState::Ready {
return base;
}
let Ok(expected) =
crate::model::ModelRegistry::resolve_embedder_fingerprint(config, Some(project_path))
else {
return base;
};
let meta_path = project_path.join(".semantex").join("meta.json");
if is_stale_for_embedder(&meta_path, &expected) {
return IndexState::Stale;
}
base
}
pub fn index_age_secs(project_path: &Path) -> Option<u64> {
let meta_path = project_path.join(".semantex").join("meta.json");
let content = std::fs::read_to_string(meta_path).ok()?;
let meta: crate::types::IndexMeta = serde_json::from_str(&content).ok()?;
let updated_epoch: u64 = meta.updated_at.trim().parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Some(now.saturating_sub(updated_epoch))
}
pub fn is_stale(meta_path: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(meta_path) else {
return true; };
let meta: IndexMeta = match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => return true, };
meta.schema_version != IndexMeta::CURRENT_SCHEMA_VERSION
}
pub fn is_stale_for_embedder(meta_path: &Path, expected_fingerprint: &str) -> bool {
if is_stale(meta_path) {
return true;
}
let Ok(content) = std::fs::read_to_string(meta_path) else {
return true;
};
match serde_json::from_str::<IndexMeta>(&content) {
Ok(meta) => meta.embedder_fingerprint != expected_fingerprint,
Err(_) => true,
}
}
pub fn is_locked(lock_path: &Path) -> bool {
let Ok(file) = std::fs::File::open(lock_path) else {
return false;
};
match file.try_lock() {
Err(std::fs::TryLockError::WouldBlock) => {
true
}
Ok(()) | Err(std::fs::TryLockError::Error(_)) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_not_indexed() {
let tmp = TempDir::new().unwrap();
assert_eq!(detect(tmp.path()), IndexState::NotIndexed);
}
#[test]
fn test_ready() {
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let meta = serde_json::json!({
"schema_version": IndexMeta::CURRENT_SCHEMA_VERSION,
"project_path": tmp.path(),
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"file_count": 10,
"chunk_count": 50,
"embedding_model": "test",
"embedding_dim": 48,
"use_bm25_stemmer": true,
"dense_backend": "coderank-hnsw",
"embedder_fingerprint": "test",
});
std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
assert_eq!(detect(tmp.path()), IndexState::Ready);
}
#[test]
fn test_stale_schema() {
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let meta = serde_json::json!({
"schema_version": 1,
"project_path": tmp.path(),
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"file_count": 10,
"chunk_count": 50,
"embedding_model": "test",
"embedding_dim": 48
});
std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
assert_eq!(detect(tmp.path()), IndexState::Stale);
}
#[test]
fn test_unreadable_meta_treated_as_stale() {
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
std::fs::write(semantex_dir.join("meta.json"), "not valid json").unwrap();
assert_eq!(detect(tmp.path()), IndexState::Stale);
}
#[test]
fn test_building_with_lock() {
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let lock_path = semantex_dir.join(".semantex.lock");
let lock_file = std::fs::File::create(&lock_path).unwrap();
lock_file.lock().expect("Failed to acquire test lock");
assert_eq!(detect(tmp.path()), IndexState::Building);
}
#[test]
fn test_pre_v0_3_schema_v7_is_stale() {
const { assert!(IndexMeta::CURRENT_SCHEMA_VERSION > 7) };
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let meta = IndexMeta {
schema_version: 7,
project_path: tmp.path().to_path_buf(),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 0,
chunk_count: 0,
embedding_model: "test".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: "coderank-hnsw".to_string(),
embedder_fingerprint: "test".to_string(),
};
let meta_json = serde_json::to_string(&meta).unwrap();
std::fs::write(semantex_dir.join("meta.json"), meta_json).unwrap();
assert_eq!(detect(tmp.path()), IndexState::Stale);
}
fn write_meta_with_fp(tmp: &TempDir, fingerprint: &str) {
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let meta = IndexMeta {
schema_version: IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: tmp.path().to_path_buf(),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 1,
chunk_count: 1,
embedding_model: "test".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: "coderank-hnsw".to_string(),
embedder_fingerprint: fingerprint.to_string(),
};
std::fs::write(
semantex_dir.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
}
#[test]
fn is_stale_for_embedder_true_on_fingerprint_mismatch() {
let tmp = TempDir::new().unwrap();
write_meta_with_fp(&tmp, "OLDFP");
let meta_path = tmp.path().join(".semantex").join("meta.json");
assert!(is_stale_for_embedder(&meta_path, "NEWFP"));
}
#[test]
fn is_stale_for_embedder_false_on_fingerprint_match() {
let tmp = TempDir::new().unwrap();
write_meta_with_fp(&tmp, "SAMEFP");
let meta_path = tmp.path().join(".semantex").join("meta.json");
assert!(!is_stale_for_embedder(&meta_path, "SAMEFP"));
}
#[test]
fn is_stale_for_embedder_true_when_schema_stale_regardless_of_fp() {
let tmp = TempDir::new().unwrap();
let semantex_dir = tmp.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let meta = serde_json::json!({
"schema_version": 1,
"project_path": tmp.path(),
"created_at": "0", "updated_at": "0",
"file_count": 1, "chunk_count": 1,
"embedding_model": "test", "embedding_dim": 48,
"use_bm25_stemmer": true,
"dense_backend": "coderank-hnsw",
"embedder_fingerprint": "SAMEFP",
});
std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
let meta_path = semantex_dir.join("meta.json");
assert!(is_stale_for_embedder(&meta_path, "SAMEFP"));
}
#[test]
fn detect_for_config_stale_on_fingerprint_mismatch() {
let tmp = TempDir::new().unwrap();
write_meta_with_fp(&tmp, "definitely-not-the-real-fingerprint");
let cfg = crate::config::SemantexConfig::default();
assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Stale);
}
#[test]
fn detect_for_config_ready_on_fingerprint_match() {
let _g = DENSE_CONTEXT_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = TempDir::new().unwrap();
let cfg = crate::config::SemantexConfig::default();
let expected =
crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
.expect("default embedder must resolve a fingerprint");
write_meta_with_fp(&tmp, &expected);
assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Ready);
}
#[test]
fn detect_for_config_falls_back_to_schema_only_on_registry_error() {
let tmp = TempDir::new().unwrap();
write_meta_with_fp(&tmp, "some-fingerprint-that-would-mismatch");
let semantex_dir = tmp.path().join(".semantex");
std::fs::write(
semantex_dir.join("models.toml"),
"this is = = not valid toml [[[",
)
.unwrap();
let cfg = crate::config::SemantexConfig::default();
assert!(
crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
.is_err(),
"test precondition: malformed models.toml must fail registry resolution"
);
assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Ready);
}
static DENSE_CONTEXT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn detect_for_config_stale_when_dense_context_differs_from_build() {
let _g = DENSE_CONTEXT_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_DENSE_CONTEXT").ok();
let tmp = TempDir::new().unwrap();
let cfg = crate::config::SemantexConfig::default();
unsafe { std::env::remove_var("SEMANTEX_DENSE_CONTEXT") };
let fp_off =
crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
.expect("resolve fp (ctx off)");
write_meta_with_fp(&tmp, &fp_off);
assert_eq!(
detect_for_config(tmp.path(), &cfg),
IndexState::Ready,
"same dense_context setting must be Ready"
);
unsafe { std::env::set_var("SEMANTEX_DENSE_CONTEXT", "1") };
let fp_on =
crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
.expect("resolve fp (ctx on)");
assert_ne!(
fp_off, fp_on,
"dense_context must change the resolved fingerprint"
);
let verdict = detect_for_config(tmp.path(), &cfg);
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_DENSE_CONTEXT", v),
None => std::env::remove_var("SEMANTEX_DENSE_CONTEXT"),
}
}
assert_eq!(
verdict,
IndexState::Stale,
"flipping SEMANTEX_DENSE_CONTEXT must mark the index Stale (auto-rebuild)"
);
}
}