use crate::cli::errors::detect_corruption;
use crate::cli::index_job::{IndexJobSnapshot, IndexJobState, JobPaths, JobStatus, new_job_id};
use crate::cli::leindex::{IndexStats, LeIndex};
use crate::cli::mcp::protocol::JsonRpcError;
use crate::cli::watcher::IndexWatcher;
use dirs;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
pub const DEFAULT_MAX_PROJECTS: usize = 5;
pub const STALE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
pub const WATCHER_ENABLE_ENV: &str = "LEINDEX_WATCHER";
pub fn watcher_enabled() -> bool {
match std::env::var(WATCHER_ENABLE_ENV) {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
}
}
pub struct ProjectRwLock {
inner: Mutex<LeIndex>,
}
impl ProjectRwLock {
pub fn new(leindex: LeIndex) -> Self {
Self {
inner: Mutex::new(leindex),
}
}
pub async fn read(&self) -> ProjectReadGuard<'_> {
ProjectReadGuard {
inner: self.inner.lock().await,
}
}
pub fn blocking_read(&self) -> ProjectReadGuard<'_> {
ProjectReadGuard {
inner: self.inner.blocking_lock(),
}
}
pub async fn write(&self) -> ProjectWriteGuard<'_> {
ProjectWriteGuard {
inner: self.inner.lock().await,
}
}
#[allow(clippy::result_unit_err)]
pub fn try_write(&self) -> Result<ProjectWriteGuard<'_>, ()> {
match self.inner.try_lock() {
Ok(guard) => Ok(ProjectWriteGuard { inner: guard }),
Err(_) => Err(()),
}
}
pub fn blocking_write(&self) -> ProjectWriteGuard<'_> {
ProjectWriteGuard {
inner: self.inner.blocking_lock(),
}
}
}
pub struct ProjectReadGuard<'a> {
inner: tokio::sync::MutexGuard<'a, LeIndex>,
}
impl std::ops::Deref for ProjectReadGuard<'_> {
type Target = LeIndex;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub struct ProjectWriteGuard<'a> {
inner: tokio::sync::MutexGuard<'a, LeIndex>,
}
impl std::ops::Deref for ProjectWriteGuard<'_> {
type Target = LeIndex;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl std::ops::DerefMut for ProjectWriteGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
pub type ProjectHandle = Arc<ProjectRwLock>;
struct RefreshGuard {
registry: Arc<ProjectRegistry>,
path: PathBuf,
}
impl Drop for RefreshGuard {
fn drop(&mut self) {
if let Ok(mut map) = self.registry.incremental_refresh_guard.try_lock() {
map.insert(self.path.clone(), false);
}
}
}
pub struct ProjectRegistry {
projects: RwLock<HashMap<PathBuf, ProjectHandle>>,
lru_order: Mutex<VecDeque<PathBuf>>,
default_project: RwLock<Option<PathBuf>>,
index_slots: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
index_jobs: Mutex<HashMap<PathBuf, Arc<IndexJobState>>>,
max_projects: usize,
watchers: Mutex<HashMap<PathBuf, IndexWatcher>>,
stale_cache: RwLock<HashMap<PathBuf, (std::time::Instant, bool)>>,
incremental_refresh_guard: Mutex<HashMap<PathBuf, bool>>,
}
impl ProjectRegistry {
pub fn new(max_projects: usize) -> Self {
Self {
projects: RwLock::new(HashMap::new()),
lru_order: Mutex::new(VecDeque::new()),
default_project: RwLock::new(None),
index_slots: Mutex::new(HashMap::new()),
index_jobs: Mutex::new(HashMap::new()),
max_projects,
watchers: Mutex::new(HashMap::new()),
stale_cache: RwLock::new(HashMap::new()),
incremental_refresh_guard: Mutex::new(HashMap::new()),
}
}
pub fn with_initial_project(max_projects: usize, leindex: LeIndex) -> Self {
let path = leindex.project_path().to_path_buf();
let handle: ProjectHandle = Arc::new(ProjectRwLock::new(leindex));
let mut map = HashMap::new();
map.insert(path.clone(), handle.clone());
let mut lru = VecDeque::new();
lru.push_back(path.clone());
let mut slots = HashMap::new();
slots.insert(path.clone(), Arc::new(Mutex::new(())));
let mut watchers = HashMap::new();
if watcher_enabled() {
if let Ok(w) = IndexWatcher::start(path.clone(), handle.clone()) {
watchers.insert(path.clone(), w);
}
}
Self {
projects: RwLock::new(map),
lru_order: Mutex::new(lru),
default_project: RwLock::new(Some(path)),
index_slots: Mutex::new(slots),
index_jobs: Mutex::new(HashMap::new()),
max_projects,
watchers: Mutex::new(watchers),
stale_cache: RwLock::new(HashMap::new()),
incremental_refresh_guard: Mutex::new(HashMap::new()),
}
}
pub async fn invalidate_stale_cache(&self, path: &Path) {
self.stale_cache.write().await.remove(path);
}
pub async fn get_or_load(
&self,
project_path: Option<&str>,
) -> Result<ProjectHandle, JsonRpcError> {
let canonical = self.resolve_path(project_path).await?;
{
let projects = self.projects.read().await;
if let Some(handle) = projects.get(&canonical) {
self.touch_lru(&canonical).await;
self.set_default(&canonical).await;
return Ok(handle.clone());
}
}
self.create_and_insert(canonical).await
}
pub async fn get_or_create(
self: &Arc<Self>,
project_path: Option<&str>,
) -> Result<ProjectHandle, JsonRpcError> {
let handle = self.get_or_load(project_path).await?;
let canonical = {
let idx = handle.read().await;
idx.project_path().to_path_buf()
};
let (needs_index, needs_refresh) = {
let idx = handle.read().await;
let not_indexed = !idx.is_indexed();
let stale = if not_indexed {
false
} else {
let cache = self.stale_cache.read().await;
if let Some((ts, result)) = cache.get(&canonical) {
if ts.elapsed() < STALE_CACHE_TTL {
*result
} else {
drop(cache);
let fresh = idx.is_stale_fast();
self.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), fresh));
fresh
}
} else {
drop(cache);
let fresh = idx.is_stale_fast();
self.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), fresh));
fresh
}
};
(not_indexed, stale)
};
if needs_index {
self.index_handle(&handle, false).await?;
} else if needs_refresh {
self.maybe_incremental_refresh(&handle, &canonical);
debug!(
"Index is stale; serving existing results while incremental refresh runs in background"
);
}
Ok(handle)
}
pub fn maybe_incremental_refresh(
self: &Arc<Self>,
_handle: &ProjectHandle,
project_path: &Path,
) {
{
let guard = self.incremental_refresh_guard.try_lock();
match guard {
Ok(mut map) => {
if *map.get(project_path).unwrap_or(&false) {
return;
}
map.insert(project_path.to_path_buf(), true);
}
Err(_) => {
return;
}
}
}
let registry = Arc::clone(self);
let path = project_path.to_path_buf();
let path_string = path.to_string_lossy().into_owned();
tokio::spawn(async move {
let _guard = RefreshGuard {
registry: Arc::clone(®istry),
path: path.clone(),
};
debug!(
project = %path.display(),
"Starting background incremental refresh"
);
let result = registry.index_project(Some(&path_string), false).await;
match result {
Ok(stats) => {
debug!(
project = %path.display(),
files_parsed = stats.files_parsed,
"Background incremental refresh completed"
);
registry.invalidate_stale_cache(&path).await;
}
Err(error) => {
warn!(
project = %path.display(),
error = %error,
"Background incremental refresh failed; existing index remains usable"
);
}
}
});
}
pub async fn index_project(
&self,
project_path: Option<&str>,
force_reindex: bool,
) -> Result<IndexStats, JsonRpcError> {
let handle = self.get_or_load(project_path).await?;
self.index_handle(&handle, force_reindex).await
}
pub async fn start_index_job(
self: &Arc<Self>,
project_path: Option<&str>,
force_reindex: bool,
wait: bool,
) -> Result<IndexJobSnapshot, JsonRpcError> {
let canonical = self.resolve_path(project_path).await?;
let previous_generation = crate::cli::index_freshness::load_health(
&crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex")),
)
.map(|health| health.generation)
.unwrap_or(0);
let storage_root = crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
let next_state_path =
JobPaths::new(&storage_root, previous_generation.saturating_add(1)).job_status();
let state = self
.select_index_job_state(&canonical, &next_state_path, force_reindex)
.await;
self.spawn_owned_index_job(
&state,
canonical,
storage_root,
previous_generation,
force_reindex,
)
.await;
if wait {
Ok(state.wait().await)
} else {
Ok(state.snapshot().await)
}
}
pub async fn get_index_job_snapshot(
&self,
project_path: Option<&str>,
) -> Result<Option<IndexJobSnapshot>, JsonRpcError> {
let canonical = self.resolve_path(project_path).await?;
let state = self.index_jobs.lock().await.get(&canonical).cloned();
Ok(match state {
Some(state) => Some(state.snapshot().await),
None => None,
})
}
async fn select_index_job_state(
&self,
canonical: &Path,
next_state_path: &Path,
force_reindex: bool,
) -> Arc<IndexJobState> {
let mut jobs = self.index_jobs.lock().await;
if let Some(existing) = jobs.get(canonical).cloned() {
let current = existing.snapshot().await;
if current.status == JobStatus::Running || !force_reindex {
return existing;
}
}
let state = Arc::new(IndexJobState::with_state_path(
new_job_id(canonical),
next_state_path.to_path_buf(),
));
jobs.insert(canonical.to_path_buf(), state.clone());
state
}
async fn spawn_owned_index_job(
self: &Arc<Self>,
state: &Arc<IndexJobState>,
path: PathBuf,
storage_root: PathBuf,
previous_generation: u64,
force_reindex: bool,
) {
if state.snapshot().await.status != JobStatus::Running || !state.try_start() {
return;
}
let registry = Arc::clone(self);
let task_state = Arc::clone(state);
tokio::spawn(async move {
let state_for_exit = Arc::clone(&task_state);
let path_for_exit = path.clone();
let inner = tokio::spawn(async move {
registry
.run_owned_index_work(
task_state,
path,
storage_root,
previous_generation,
force_reindex,
)
.await;
});
if let Err(join_error) = inner.await {
if join_error.is_panic() {
let payload = join_error.into_panic();
let panic_msg = payload
.downcast_ref::<&str>()
.map(|message| message.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
warn!(
project = %path_for_exit.display(),
"Indexing task panicked: {}; marking job as failed", panic_msg
);
state_for_exit
.fail(format!("indexing panicked: {panic_msg}"))
.await;
} else {
warn!(
project = %path_for_exit.display(),
"Indexing task was cancelled; marking job as failed"
);
state_for_exit.fail("indexing task was cancelled").await;
}
}
});
}
async fn run_owned_index_work(
self: &Arc<Self>,
state: Arc<IndexJobState>,
path: PathBuf,
storage_root: PathBuf,
previous_generation: u64,
force_reindex: bool,
) {
let mut resident_core_generation = previous_generation;
state.set_phase("scan", 0, 0).await;
if std::env::var("LEINDEX_INJECT_PANIC")
.ok()
.is_some_and(|value| value == "1")
{
panic!("injected test panic for index job lifecycle test");
}
match self
.await_index_with_job_progress(
&state,
&path,
&storage_root,
&mut resident_core_generation,
force_reindex,
)
.await
{
Ok(_) => {
self.finish_owned_index(&state, &path, previous_generation)
.await
}
Err(error) => {
self.preserve_core_after_job_error(
&state,
&path,
&storage_root,
previous_generation,
resident_core_generation,
&error,
)
.await;
}
}
}
async fn await_index_with_job_progress(
&self,
state: &IndexJobState,
path: &Path,
storage_root: &Path,
resident_core_generation: &mut u64,
force_reindex: bool,
) -> Result<IndexStats, JsonRpcError> {
let path_string = path.to_string_lossy().into_owned();
let indexing = self.index_project(Some(&path_string), force_reindex);
tokio::pin!(indexing);
loop {
tokio::select! {
result = &mut indexing => break result,
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
self.update_job_progress(state, path, storage_root, resident_core_generation).await;
}
}
}
}
async fn update_job_progress(
&self,
state: &IndexJobState,
path: &Path,
storage_root: &Path,
resident_core_generation: &mut u64,
) {
let Some(health) = crate::cli::index_freshness::load_health(storage_root) else {
return;
};
if health.phase == crate::cli::leindex::IndexPhase::Complete
&& health.generation > *resident_core_generation
{
match self.refresh_loaded_from_active_generation(path).await {
Ok(()) => {
*resident_core_generation = health.generation;
state.mark_core_published(health.generation).await;
}
Err(error) => {
warn!(
project = %path.display(),
"Core generation is published but resident hydration is pending: {error}"
);
}
}
}
let phase = format!("{:?}", health.phase).to_ascii_lowercase();
let total = health.indexed_file_count;
let completed = if health.phase == crate::cli::leindex::IndexPhase::Complete {
total
} else {
0
};
state.set_phase(phase, completed, total).await;
}
async fn finish_owned_index(
&self,
state: &IndexJobState,
path: &Path,
previous_generation: u64,
) {
let generation = crate::cli::leindex::resolve_existing_storage_path(path)
.and_then(|storage| crate::cli::index_freshness::load_health(&storage))
.map(|health| health.generation)
.unwrap_or(previous_generation.saturating_add(1));
let neural_path =
crate::cli::leindex::resolve_existing_storage_path(path).and_then(|storage| {
crate::cli::index_freshness::load_health(&storage).map(|health| {
storage
.join("generations")
.join(health.generation.to_string())
.join("neural_embeddings.bin")
})
});
if neural_path.as_ref().is_some_and(|path| {
path.is_file() && std::fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0)
}) {
state.mark_neural_published().await;
}
state.complete(generation).await;
let storage_root = crate::cli::leindex::resolve_existing_storage_path(path)
.unwrap_or_else(|| path.join(".leindex"));
let job_paths = crate::cli::index_job::JobPaths::new(&storage_root, generation);
let _ = crate::cli::index_job::mark_checkpoint_complete(&job_paths.state(), generation);
}
async fn preserve_core_after_job_error(
&self,
state: &IndexJobState,
path: &Path,
storage_root: &Path,
previous_generation: u64,
resident_core_generation: u64,
error: &JsonRpcError,
) {
if let Some(health) = crate::cli::index_freshness::load_health(storage_root) {
if health.generation > previous_generation {
let core_loaded = if health.generation > resident_core_generation {
match self.refresh_loaded_from_active_generation(path).await {
Ok(()) => true,
Err(refresh_error) => {
warn!(
project = %path.display(),
"Core generation remains durable but resident hydration failed: {refresh_error}"
);
false
}
}
} else {
true
};
if core_loaded {
state.mark_core_published(health.generation).await;
}
}
}
state.fail(error.to_string()).await;
}
pub async fn len(&self) -> usize {
self.projects.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.projects.read().await.is_empty()
}
pub async fn loaded_projects(&self) -> Vec<PathBuf> {
self.projects.read().await.keys().cloned().collect()
}
pub async fn evict(&self, path: &Path) {
let removed = {
let mut projects = self.projects.write().await;
projects.remove(path)
};
if let Some(handle) = removed {
if let Ok(mut idx) = handle.try_write() {
if let Err(e) = idx.close() {
warn!(
"Failed to close storage for evicted project {}: {}",
path.display(),
e
);
}
}
info!("Evicted project: {}", path.display());
}
let mut lru = self.lru_order.lock().await;
lru.retain(|p| p != path);
let mut slots = self.index_slots.lock().await;
slots.remove(path);
let mut watchers = self.watchers.lock().await;
watchers.remove(path);
self.stale_cache.write().await.remove(path);
self.incremental_refresh_guard.lock().await.remove(path);
}
async fn resolve_path(&self, project_path: Option<&str>) -> Result<PathBuf, JsonRpcError> {
let path = if let Some(raw) = project_path {
Path::new(raw).to_path_buf()
} else {
let default = self.default_project.read().await;
default.clone().ok_or_else(|| {
JsonRpcError::invalid_params(
"No project_path provided and no project has been loaded yet. \
Pass project_path on the first call.",
)
})?
};
let canonical = path.canonicalize().map_err(|e| {
JsonRpcError::invalid_params(format!(
"Cannot resolve project_path '{}': {}",
path.display(),
e
))
})?;
if canonical.parent().is_none() {
return Err(JsonRpcError::invalid_params(
"Refusing to index root directory. Specify a project subdirectory.".to_string(),
));
}
if let Some(home_dir) = dirs::home_dir() {
let home_canonical = home_dir.canonicalize().unwrap_or(home_dir);
if canonical == home_canonical {
return Err(JsonRpcError::invalid_params(
"Refusing to index home directory. Specify a project subdirectory.".to_string(),
));
}
}
Ok(canonical)
}
async fn create_and_insert(&self, canonical: PathBuf) -> Result<ProjectHandle, JsonRpcError> {
self.evict_lru_if_needed().await;
{
let projects = self.projects.read().await;
if let Some(handle) = projects.get(&canonical) {
self.touch_lru(&canonical).await;
self.set_default(&canonical).await;
return Ok(handle.clone());
}
}
let mut leindex = LeIndex::new(&canonical).map_err(|e| {
JsonRpcError::init_failed(&canonical.display().to_string(), &e.to_string())
})?;
let hydration_started = std::time::Instant::now();
let hydration_result = leindex.load_from_active_storage();
let hydrate_ms = hydration_started
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64;
tracing::debug!(
project = %canonical.display(),
hydrate_ms,
"MCP project hydration attempt complete"
);
crate::cli::mcp::request_meta::record_hydrate_ms(hydrate_ms);
if let Err(e) = hydration_result {
warn!(
"Failed to load project from storage for {}: {}. \
The project will be auto-indexed on first tool call.",
canonical.display(),
e
);
}
let corruption =
detect_corruption(&canonical).unwrap_or(crate::cli::errors::CorruptionStatus::Healthy);
if !corruption.is_usable() {
warn!(
"Corruption detected in {}: {}. Auto-repairing...",
canonical.display(),
corruption.message()
);
let storage_path = crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
if restore_latest_generation(&storage_path) {
warn!(
"Rolled back {} to latest usable generation; preserved corrupt root artifact",
canonical.display()
);
}
let mut fresh = LeIndex::new(&canonical).map_err(|e| {
JsonRpcError::init_failed(
&canonical.display().to_string(),
&format!(
"Original: {}. Preserving artifacts: {}",
corruption.message(),
e
),
)
})?;
fresh.index_project(true).map_err(|e| {
JsonRpcError::indexing_failed(format!("Auto-repair reindex failed: {}", e))
})?;
leindex = fresh;
}
let handle: ProjectHandle = Arc::new(ProjectRwLock::new(leindex));
{
let mut projects = self.projects.write().await;
projects.insert(canonical.clone(), handle.clone());
}
if watcher_enabled() {
let mut watchers = self.watchers.lock().await;
if !watchers.contains_key(&canonical) {
if let Ok(w) = IndexWatcher::start(canonical.clone(), handle.clone()) {
watchers.insert(canonical.clone(), w);
}
}
}
self.touch_lru(&canonical).await;
self.set_default(&canonical).await;
let mut slots = self.index_slots.lock().await;
slots
.entry(canonical.clone())
.or_insert_with(|| Arc::new(Mutex::new(())));
info!(
"Loaded project into registry: {} ({} total)",
canonical.display(),
self.projects.read().await.len()
);
Ok(handle)
}
async fn index_handle(
&self,
handle: &ProjectHandle,
force_reindex: bool,
) -> Result<IndexStats, JsonRpcError> {
let project_path = {
let idx = handle.read().await;
idx.project_path().to_path_buf()
};
let slot = self.index_slot_for(&project_path).await;
let _slot_guard = slot.lock().await;
if !force_reindex {
let cached = {
let idx = handle.read().await;
if idx.is_indexed() && !idx.is_stale_fast() {
Some(idx.get_stats().clone())
} else {
None
}
};
if let Some(stats) = cached {
return Ok(stats);
}
}
debug!(
"Indexing project (consolidated): {} force_reindex={}",
project_path.display(),
force_reindex
);
let previous_generation = crate::cli::index_freshness::load_health(
&crate::cli::leindex::resolve_existing_storage_path(&project_path)
.unwrap_or_else(|| project_path.join(".leindex")),
)
.map(|health| health.generation)
.unwrap_or(0);
let path_for_blocking = project_path.clone();
let indexing = tokio::task::spawn_blocking(move || {
let mut temp = LeIndex::new(&path_for_blocking).map_err(|e| {
JsonRpcError::init_failed(&path_for_blocking.display().to_string(), &e.to_string())
})?;
temp.index_project(force_reindex)
.map_err(|e| JsonRpcError::indexing_failed(format!("Indexing failed: {}", e)))?;
Ok::<LeIndex, JsonRpcError>(temp)
});
tokio::pin!(indexing);
let mut resident_core_generation = previous_generation;
let indexing_result = loop {
tokio::select! {
result = &mut indexing => break result,
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
self.refresh_resident_core_if_published(
&project_path,
&mut resident_core_generation,
)
.await;
}
}
};
let temp = match indexing_result {
Ok(Ok(temp)) => temp,
Ok(Err(error)) => {
let core_published = self
.refresh_core_after_index_failure(&project_path, resident_core_generation)
.await;
let message = error.to_string();
if is_transient_storage_open_failure(&message) {
warn!(
project = %project_path.display(),
"Transient storage-open failure (lock contention); \
leaving generation status unchanged (not bricking): {message}"
);
} else {
mark_index_failure(&project_path, &message, core_published);
}
return Err(error);
}
Err(error) => {
let error = JsonRpcError::internal_error(format!("Task join error: {}", error));
let core_published = self
.refresh_core_after_index_failure(&project_path, resident_core_generation)
.await;
mark_index_failure(&project_path, &error.to_string(), core_published);
return Err(error);
}
};
{
let mut idx = handle.write().await;
*idx = temp;
}
self.stale_cache.write().await.remove(&project_path);
let stats = {
let idx = handle.read().await;
idx.get_stats().clone()
};
Ok(stats)
}
async fn index_slot_for(&self, path: &Path) -> Arc<Mutex<()>> {
let mut slots = self.index_slots.lock().await;
slots
.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
async fn touch_lru(&self, path: &Path) {
let mut lru = self.lru_order.lock().await;
lru.retain(|p| p != path);
lru.push_back(path.to_path_buf());
}
async fn set_default(&self, path: &Path) {
let mut default = self.default_project.write().await;
*default = Some(path.to_path_buf());
}
pub async fn set_default_path(&self, path: PathBuf) {
let canonical = path.canonicalize().unwrap_or_else(|err| {
warn!(
"Failed to canonicalize default project path {}: {}",
path.display(),
err
);
path.clone()
});
let mut default = self.default_project.write().await;
*default = Some(canonical);
}
pub async fn default_project_path(&self) -> Result<PathBuf, JsonRpcError> {
self.resolve_path(None).await
}
pub async fn try_get_loaded(&self, path: &Path) -> Option<ProjectHandle> {
self.projects.read().await.get(path).cloned()
}
async fn refresh_resident_core_if_published(&self, path: &Path, resident_generation: &mut u64) {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(path)
.unwrap_or_else(|| path.join(".leindex"));
let Some(health) = crate::cli::index_freshness::load_health(&storage_path) else {
return;
};
if health.phase != crate::cli::leindex::IndexPhase::Complete
|| health.generation <= *resident_generation
{
return;
}
match self.refresh_loaded_from_active_generation(path).await {
Ok(()) => *resident_generation = health.generation,
Err(error) => debug!(
project = %path.display(),
"Published core generation is waiting for resident hydration: {error}"
),
}
}
async fn refresh_loaded_from_active_generation(&self, path: &Path) -> Result<(), JsonRpcError> {
let Some(handle) = self.try_get_loaded(path).await else {
return Ok(());
};
tokio::task::spawn_blocking(move || {
let mut index = handle.blocking_write();
index.load_from_active_storage().map_err(|error| {
JsonRpcError::internal_error(format!(
"Failed to hydrate published core generation: {error:#}"
))
})
})
.await
.map_err(|error| {
JsonRpcError::internal_error(format!("Core hydration task failed: {error}"))
})?
}
async fn refresh_core_after_index_failure(
&self,
path: &Path,
previous_generation: u64,
) -> bool {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(path)
.unwrap_or_else(|| path.join(".leindex"));
let Some(health) = crate::cli::index_freshness::load_health(&storage_path) else {
return false;
};
if health.phase != crate::cli::leindex::IndexPhase::Complete
|| health.generation <= previous_generation
{
return false;
}
if let Err(refresh_error) = self.refresh_loaded_from_active_generation(path).await {
debug!(
project = %path.display(),
"Published core generation could not be made resident after indexing failure: {refresh_error}"
);
}
true
}
async fn evict_lru_if_needed(&self) {
let current_count = self.projects.read().await.len();
if current_count < self.max_projects {
return;
}
let evict_path = {
let mut lru = self.lru_order.lock().await;
lru.pop_front()
};
if let Some(path) = evict_path {
let removed = {
let mut projects = self.projects.write().await;
projects.remove(&path)
};
if let Some(handle) = removed {
if let Ok(mut idx) = handle.try_write() {
if let Err(e) = idx.close() {
warn!(
"Failed to close storage for LRU-evicted project {}: {}",
path.display(),
e
);
}
}
}
let mut slots = self.index_slots.lock().await;
slots.remove(&path);
let mut watchers = self.watchers.lock().await;
watchers.remove(&path);
self.stale_cache.write().await.remove(&path);
self.index_jobs.lock().await.remove(&path);
if let Ok(mut guard) = self.incremental_refresh_guard.try_lock() {
guard.remove(&path);
}
info!(
"Evicted LRU project: {} (capacity: {})",
path.display(),
self.max_projects
);
}
}
}
fn restore_latest_generation(storage_path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(storage_path.join("generations")) else {
return false;
};
let mut generations = entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<u64>().ok())
.collect::<Vec<_>>();
generations.sort_unstable_by(|a, b| b.cmp(a));
for generation in generations {
let source = storage_path
.join("generations")
.join(generation.to_string())
.join("leindex.db");
if !source.is_file() {
continue;
}
let target = storage_path.join("leindex.db");
if target.is_file() {
let backup = storage_path.join(format!("leindex.db.corrupt-{generation}"));
if std::fs::rename(&target, &backup).is_err() {
continue;
}
}
let next = storage_path.join("leindex.db.recovery.next");
if std::fs::copy(&source, &next).is_err() || std::fs::rename(&next, &target).is_err() {
let _ = std::fs::remove_file(&next);
return false;
}
let _ = std::fs::write(storage_path.join("CURRENT"), format!("{generation}\n"));
return true;
}
false
}
fn is_transient_storage_open_failure(message: &str) -> bool {
message.contains("[transient:lock-contention]")
}
fn mark_index_failure(project_path: &Path, message: &str, core_published: bool) {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(project_path)
.unwrap_or_else(|| project_path.join(".leindex"));
let previous = crate::cli::index_freshness::load_health(&storage_path).unwrap_or_default();
let health = crate::cli::leindex::IndexHealth {
generation: previous.generation,
phase: previous.phase,
status: if core_published {
previous.status
} else {
crate::cli::leindex::ComponentStatus::Failed
},
head_oid: previous.head_oid,
tree_oid: previous.tree_oid,
indexed_file_count: previous.indexed_file_count,
dirty_file_count: previous.dirty_file_count,
changed_unindexed_count: previous.changed_unindexed_count,
indexed_at_unix_ms: previous.indexed_at_unix_ms,
last_failure_phase: Some(if core_published {
crate::cli::leindex::IndexPhase::Neural
} else {
previous.phase
}),
last_failure: Some(message.to_string()),
};
let _ = crate::cli::index_freshness::save_health(&storage_path, &health);
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_registry_creation() {
let registry = ProjectRegistry::new(5);
assert_eq!(registry.len().await, 0);
}
#[test]
fn post_core_failure_preserves_published_health() {
let temp = tempfile::tempdir().unwrap();
let storage = temp.path().join(".leindex");
let health = crate::cli::leindex::IndexHealth {
generation: 4,
phase: crate::cli::leindex::IndexPhase::Complete,
status: crate::cli::leindex::ComponentStatus::Fresh,
indexed_at_unix_ms: Some(1),
..Default::default()
};
crate::cli::index_freshness::save_health(&storage, &health).unwrap();
mark_index_failure(temp.path(), "neural failed", true);
let recorded = crate::cli::index_freshness::load_health(&storage).unwrap();
assert_eq!(recorded.status, crate::cli::leindex::ComponentStatus::Fresh);
assert_eq!(
recorded.last_failure_phase,
Some(crate::cli::leindex::IndexPhase::Neural)
);
assert_eq!(recorded.last_failure.as_deref(), Some("neural failed"));
}
#[test]
fn restore_latest_generation_preserves_corrupt_root() {
let temp = tempfile::tempdir().unwrap();
let storage = temp.path().join(".leindex");
let generation = storage.join("generations/3");
std::fs::create_dir_all(&generation).unwrap();
std::fs::write(storage.join("leindex.db"), b"corrupt").unwrap();
std::fs::write(generation.join("leindex.db"), b"usable").unwrap();
assert!(restore_latest_generation(&storage));
assert_eq!(
std::fs::read(storage.join("leindex.db")).unwrap(),
b"usable"
);
assert_eq!(
std::fs::read(storage.join("leindex.db.corrupt-3")).unwrap(),
b"corrupt"
);
}
#[tokio::test]
async fn test_registry_no_default_project_error() {
let registry = ProjectRegistry::new(5);
let result = registry.get_or_load(None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_registry_nonexistent_path_error() {
let registry = ProjectRegistry::new(5);
let result = registry.get_or_load(Some("/nonexistent/path/12345")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_registry_with_initial_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
assert_eq!(registry.len().await, 1);
let handle = registry.get_or_load(None).await;
assert!(handle.is_ok());
}
#[tokio::test]
async fn core_generation_refreshes_the_resident_handle() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn resident_core() {}\n").unwrap();
let mut builder = LeIndex::new(tmp.path()).unwrap();
builder.index_project(true).unwrap();
drop(builder);
let resident = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(2, resident);
let canonical = tmp.path().canonicalize().unwrap();
let handle = registry
.get_or_load(Some(canonical.to_str().unwrap()))
.await
.unwrap();
assert!(handle.read().await.pdg().is_none());
registry
.refresh_loaded_from_active_generation(&canonical)
.await
.unwrap();
let guard = handle.read().await;
assert!(guard.pdg().is_some());
assert!(guard.search_engine().node_count() > 0);
}
#[tokio::test]
async fn test_registry_same_project_returns_same_handle() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let path_str = tmp.path().to_string_lossy().to_string();
let h1 = registry.get_or_load(Some(&path_str)).await.unwrap();
let h2 = registry.get_or_load(Some(&path_str)).await.unwrap();
assert!(Arc::ptr_eq(&h1, &h2));
}
#[tokio::test]
async fn test_registry_two_different_projects() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
std::fs::write(tmp1.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(tmp2.path().join("b.rs"), "fn b() {}\n").unwrap();
let leindex = LeIndex::new(tmp1.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let p2 = tmp2.path().to_string_lossy().to_string();
let h2 = registry.get_or_load(Some(&p2)).await.unwrap();
assert_eq!(registry.len().await, 2);
let p1 = tmp1.path().to_string_lossy().to_string();
let h1 = registry.get_or_load(Some(&p1)).await.unwrap();
assert!(!Arc::ptr_eq(&h1, &h2));
}
#[tokio::test]
async fn test_registry_eviction_at_capacity() {
let dirs: Vec<_> = (0..3)
.map(|i| {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join(format!("f{}.rs", i)), "fn f() {}\n").unwrap();
d
})
.collect();
let leindex = LeIndex::new(dirs[0].path()).unwrap();
let registry = ProjectRegistry::with_initial_project(2, leindex);
let p1 = dirs[1].path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p1)).await.unwrap();
assert_eq!(registry.len().await, 2);
let p2 = dirs[2].path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p2)).await.unwrap();
assert_eq!(registry.len().await, 2);
let loaded = registry.loaded_projects().await;
let canonical0 = dirs[0].path().canonicalize().unwrap();
assert!(!loaded.contains(&canonical0));
}
#[tokio::test]
async fn test_registry_evicted_project_reloads() {
let dirs: Vec<_> = (0..3)
.map(|i| {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join(format!("f{}.rs", i)), "fn f() {}\n").unwrap();
d
})
.collect();
let leindex = LeIndex::new(dirs[0].path()).unwrap();
let registry = ProjectRegistry::with_initial_project(2, leindex);
let p1 = dirs[1].path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p1)).await.unwrap();
let p2 = dirs[2].path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p2)).await.unwrap();
let p0 = dirs[0].path().to_string_lossy().to_string();
let result = registry.get_or_load(Some(&p0)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_registry_default_project_tracks_last_used() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
std::fs::write(tmp1.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(tmp2.path().join("b.rs"), "fn b() {}\n").unwrap();
let leindex = LeIndex::new(tmp1.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let h1 = registry.get_or_load(None).await.unwrap();
let path1 = h1.read().await.project_path().to_path_buf();
assert_eq!(path1, tmp1.path().canonicalize().unwrap());
let p2 = tmp2.path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p2)).await.unwrap();
let h2 = registry.get_or_load(None).await.unwrap();
let path2 = h2.read().await.project_path().to_path_buf();
assert_eq!(path2, tmp2.path().canonicalize().unwrap());
}
#[tokio::test]
async fn test_project_rwlock_concurrent_access_no_deadlock() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let handle = registry.get_or_load(None).await.unwrap();
let mut handles = Vec::new();
for i in 0..10 {
let h = handle.clone();
handles.push(tokio::spawn(async move {
if i % 2 == 0 {
let guard = h.read().await;
let path = guard.project_path().to_path_buf();
assert!(path.exists());
} else {
let guard = h.write().await;
let path = guard.project_path().to_path_buf();
assert!(path.exists());
}
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_project_rwlock_try_write_returns_err_when_locked() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let handle = registry.get_or_load(None).await.unwrap();
let _guard = handle.read().await;
let result = handle.try_write();
assert!(result.is_err());
}
#[test]
fn test_project_rwlock_blocking_write() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let handle: ProjectHandle = Arc::new(ProjectRwLock::new(leindex));
let h = handle.clone();
let result = std::thread::spawn(move || {
let guard = h.blocking_write();
guard.project_path().to_path_buf()
})
.join()
.unwrap();
assert!(result.exists());
}
#[tokio::test]
async fn test_evict_removes_slot_bookkeeping() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
assert_eq!(registry.len().await, 1);
registry.evict(&canonical).await;
assert_eq!(registry.len().await, 0);
let path_str = tmp.path().to_string_lossy().to_string();
let result = registry.get_or_load(Some(&path_str)).await;
assert!(result.is_ok(), "re-loading after eviction should succeed");
assert_eq!(registry.len().await, 1);
}
#[tokio::test]
async fn test_slot_map_reflects_only_live_projects() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
std::fs::write(tmp1.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(tmp2.path().join("b.rs"), "fn b() {}\n").unwrap();
let leindex = LeIndex::new(tmp1.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let p2 = tmp2.path().to_string_lossy().to_string();
let _ = registry.get_or_load(Some(&p2)).await.unwrap();
assert_eq!(registry.len().await, 2);
let canonical1 = tmp1.path().canonicalize().unwrap();
registry.evict(&canonical1).await;
assert_eq!(registry.len().await, 1);
let loaded = registry.loaded_projects().await;
let canonical2 = tmp2.path().canonicalize().unwrap();
assert!(loaded.contains(&canonical2));
assert!(!loaded.contains(&canonical1));
}
#[tokio::test]
async fn test_evict_cleans_stale_cache() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
registry
.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), false));
assert!(registry.stale_cache.read().await.contains_key(&canonical));
registry.evict(&canonical).await;
assert!(
!registry.stale_cache.read().await.contains_key(&canonical),
"stale cache entry should be removed on evict"
);
}
#[tokio::test]
async fn test_invalidate_stale_cache_removes_entry() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
registry
.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), false));
assert!(registry.stale_cache.read().await.contains_key(&canonical));
registry.invalidate_stale_cache(&canonical).await;
assert!(
!registry.stale_cache.read().await.contains_key(&canonical),
"stale cache entry must be removed on invalidate"
);
}
#[tokio::test]
async fn test_invalidate_stale_cache_requires_canonical_input() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("main.rs"), "fn main() {}\n").unwrap();
let leindex = LeIndex::new(tmp.path()).unwrap();
let registry = ProjectRegistry::with_initial_project(5, leindex);
let canonical = tmp.path().canonicalize().unwrap();
registry
.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), false));
registry.invalidate_stale_cache(&canonical).await;
assert!(
!registry.stale_cache.read().await.contains_key(&canonical),
"stale cache entry must be removed on invalidate with canonical input"
);
}
}