1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::Serialize;
7
8use crate::core::bm25_index::BM25Index;
9use crate::core::graph_index::{self, ProjectIndex};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum State {
13 Idle,
14 Building,
15 Ready,
16 Failed,
17}
18
19#[derive(Debug, Clone)]
20struct Component {
21 state: State,
22 started_ms: Option<u64>,
23 finished_ms: Option<u64>,
24 duration_ms: Option<u64>,
25 last_error: Option<String>,
26 note: Option<String>,
30}
31
32impl Component {
33 fn new() -> Self {
34 Self {
35 state: State::Idle,
36 started_ms: None,
37 finished_ms: None,
38 duration_ms: None,
39 last_error: None,
40 note: None,
41 }
42 }
43}
44
45#[derive(Debug)]
46struct ProjectBuild {
47 worker_running: bool,
48 graph_run_done: bool,
52 bm25_run_done: bool,
54 warm_triggered: bool,
58 graph: Component,
59 bm25: Component,
60 semantic: Component,
64}
65
66impl ProjectBuild {
67 fn new() -> Self {
68 Self {
69 worker_running: false,
70 graph_run_done: true,
71 bm25_run_done: true,
72 warm_triggered: false,
73 graph: Component::new(),
74 bm25: Component::new(),
75 semantic: Component::new(),
76 }
77 }
78}
79
80static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
88
89fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
90 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
94 let mut map = registry()
95 .lock()
96 .unwrap_or_else(std::sync::PoisonError::into_inner);
97 map.entry(project_root.to_string())
98 .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
99 .clone()
100}
101
102fn now_ms() -> u64 {
103 SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .unwrap_or_default()
106 .as_millis() as u64
107}
108
109fn bm25_index_lock_name(root: &Path) -> String {
114 format!(
115 "bm25-idx-{}",
116 &crate::core::index_namespace::namespace_hash(root)[..8]
117 )
118}
119
120fn semantic_index_lock_name(root: &Path) -> String {
123 format!(
124 "semantic-idx-{}",
125 &crate::core::index_namespace::namespace_hash(root)[..8]
126 )
127}
128
129const SEMANTIC_LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_hours(24);
132
133fn start_component(c: &mut Component) {
134 c.state = State::Building;
135 c.started_ms = Some(now_ms());
136 c.finished_ms = None;
137 c.duration_ms = None;
138 c.last_error = None;
139 c.note = None;
140}
141
142fn finish_ok(c: &mut Component) {
143 c.state = State::Ready;
144 let end = now_ms();
145 c.finished_ms = Some(end);
146 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
147}
148
149fn finish_err(c: &mut Component, e: String) {
150 c.state = State::Failed;
151 let end = now_ms();
152 c.finished_ms = Some(end);
153 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
154 c.last_error = Some(e);
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum WarmNeed {
163 None,
165 Search,
167 Heavy,
169}
170
171#[must_use]
175pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
176 match tool {
177 "ctx_search" => WarmNeed::Search,
178 "ctx_graph" | "ctx_callgraph" | "ctx_artifacts" | "ctx_provider" | "ctx_compose" => {
180 WarmNeed::Heavy
181 }
182 _ => WarmNeed::None,
183 }
184}
185
186pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
193 if project_root.is_empty() {
194 return false;
195 }
196 match warm_need_for_tool(tool) {
197 WarmNeed::None => false,
198 WarmNeed::Search => {
199 crate::core::search_index::ensure_background(project_root, true, false);
202 false
203 }
204 WarmNeed::Heavy => {
205 let entry = entry_for(project_root);
206 let first_warm = {
207 let mut s = entry
208 .lock()
209 .unwrap_or_else(std::sync::PoisonError::into_inner);
210 if s.warm_triggered {
211 false
212 } else {
213 s.warm_triggered = true;
214 true
215 }
216 };
217 if first_warm {
218 ensure_all_background(project_root);
219 }
220 first_warm
221 }
222 }
223}
224
225const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
229
230fn nudge_daemon_index(project_root: &str) {
242 if crate::daemon::is_foreground_daemon() {
244 return;
245 }
246 let root = project_root.to_string();
247 let _ = std::thread::Builder::new()
248 .name("leanctx-index-nudge".to_string())
249 .spawn(move || {
250 if !crate::daemon::is_daemon_running() {
251 return;
252 }
253 let Ok(rt) = tokio::runtime::Runtime::new() else {
254 return;
255 };
256 let body = serde_json::json!({ "root": root }).to_string();
257 rt.block_on(async {
258 let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
259 .await;
260 });
261 });
262}
263
264fn try_claim_worker(project_root: &str) -> bool {
268 let state = entry_for(project_root);
269 let mut s = state
270 .lock()
271 .unwrap_or_else(std::sync::PoisonError::into_inner);
272 if s.worker_running {
273 false
274 } else {
275 s.worker_running = true;
276 s.graph_run_done = false;
277 s.bm25_run_done = false;
278 true
279 }
280}
281
282pub fn ensure_all_background(project_root: &str) {
283 if !try_claim_worker(project_root) {
284 return;
285 }
286
287 if !crate::core::index_filter::cli_overlay_active() {
296 nudge_daemon_index(project_root);
297 }
298
299 let state = entry_for(project_root);
300 let root = project_root.to_string();
301 let indexer = move || run_build_worker(&root);
302
303 let spawned = std::thread::Builder::new()
307 .name("leanctx-index".to_string())
308 .stack_size(INDEXER_STACK_BYTES)
309 .spawn(indexer);
310 if spawned.is_err() {
311 let mut s = state
314 .lock()
315 .unwrap_or_else(std::sync::PoisonError::into_inner);
316 s.worker_running = false;
317 }
318}
319
320fn run_build_worker(root: &str) {
325 let _slot = WorkerSlot(root.to_string());
329
330 start_index_memory_guard("build_worker");
337
338 let graph_state = entry_for(root);
344 let graph_root = root.to_string();
345 let build_graph = move || {
346 {
347 let mut s = graph_state
348 .lock()
349 .unwrap_or_else(std::sync::PoisonError::into_inner);
350 start_component(&mut s.graph);
351 }
352 let guard = crate::core::index_progress::ProgressGuard::new(
355 graph_root.clone(),
356 crate::core::index_progress::IndexComponent::Graph,
357 );
358 guard.report(0, 0);
359 let graph_result = std::panic::catch_unwind(|| {
360 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
361 crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
365 });
366 if graph_result.is_ok() {
367 crate::core::call_graph::CallGraph::invalidate(&graph_root);
370 let _ = std::panic::catch_unwind(|| {
373 crate::core::graph_enricher::refresh_semantic_edges_in_background(&graph_root);
374 });
375 }
376 drop(guard);
377 crate::core::telemetry_features::record(
378 crate::core::telemetry_features::Feature::GraphIndex,
379 graph_result.is_ok(),
380 );
381 {
382 let mut s = graph_state
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 if let Ok(()) = graph_result {
386 finish_ok(&mut s.graph);
387 } else {
388 finish_err(&mut s.graph, "graph index build panicked".to_string());
389 }
390 s.graph_run_done = true;
391 }
392 };
393
394 let bm25_state = entry_for(root);
395 let bm25_root = root.to_string();
396 let build_bm25 = move || {
397 {
398 let mut s = bm25_state
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner);
401 start_component(&mut s.bm25);
402 }
403 let _progress = crate::core::index_progress::ProgressGuard::new(
404 bm25_root.clone(),
405 crate::core::index_progress::IndexComponent::Bm25,
406 );
407 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
408 let root_pb = Path::new(&bm25_root);
409 let lock_name = bm25_index_lock_name(root_pb);
410 let _lock = crate::core::startup_guard::try_acquire_lock(
411 &lock_name,
412 std::time::Duration::from_millis(800),
413 std::time::Duration::from_mins(3),
414 );
415 if _lock.is_none() {
416 tracing::info!(
417 "[bm25: another process is building {bm25_root} — loading the shared index]"
418 );
419 let idx = BM25Index::load(root_pb).unwrap_or_default();
420 return (idx.doc_count, None);
421 }
422 let idx = BM25Index::load_or_build(root_pb);
423 let outcome = idx.save(root_pb);
424 (idx.doc_count, Some(outcome))
425 }));
426 if !matches!(bm, Ok((_, None))) {
428 crate::core::telemetry_features::record(
429 crate::core::telemetry_features::Feature::Bm25Index,
430 bm.is_ok(),
431 );
432 }
433 {
434 let mut s = bm25_state
435 .lock()
436 .unwrap_or_else(std::sync::PoisonError::into_inner);
437 if let Ok((doc_count, save_res)) = bm {
438 finish_ok(&mut s.bm25);
439 s.bm25.note = Some(match save_res {
440 Some(outcome) => bm25_build_note(doc_count, &outcome),
441 None => format!(
442 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
443 ),
444 });
445 } else {
446 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
447 }
448 s.bm25_run_done = true;
449 }
450 };
451
452 run_memory_bounded_phases(
453 build_graph,
454 build_bm25,
455 || crate::core::search_index::ensure_background(root, true, false),
456 || {
457 crate::core::content_cache::clear();
458 crate::core::memory_guard::force_purge();
459 },
460 );
461
462 crate::core::content_cache::trim_oldest_percent(75);
469 crate::core::memory_guard::force_purge();
470}
471
472struct WorkerSlot(String);
475
476impl Drop for WorkerSlot {
477 fn drop(&mut self) {
478 let state = entry_for(&self.0);
479 let mut s = state
480 .lock()
481 .unwrap_or_else(std::sync::PoisonError::into_inner);
482 s.worker_running = false;
483 }
484}
485
486fn run_memory_bounded_phases<G, B, S, R>(graph: G, bm25: B, search: S, mut reclaim: R)
489where
490 G: FnOnce(),
491 B: FnOnce(),
492 S: FnOnce(),
493 R: FnMut(),
494{
495 graph();
496 reclaim();
497 bm25();
498 reclaim();
499 search();
500}
501
502pub fn build_semantic(project_root: &str) {
507 let state = entry_for(project_root);
508 let root = Path::new(project_root);
509
510 let Some(_lock) = crate::core::startup_guard::try_acquire_lock(
511 &semantic_index_lock_name(root),
512 std::time::Duration::ZERO,
513 SEMANTIC_LOCK_STALE_AFTER,
514 ) else {
515 let mut s = state
516 .lock()
517 .unwrap_or_else(std::sync::PoisonError::into_inner);
518 s.semantic.note = Some(
519 "another lean-ctx process is already building the semantic index for this project; \
520 it saves progress as it goes — check `lean-ctx index status` later"
521 .to_string(),
522 );
523 return;
524 };
525
526 {
527 let mut s = state
528 .lock()
529 .unwrap_or_else(std::sync::PoisonError::into_inner);
530 start_component(&mut s.semantic);
531 }
532
533 let _progress = crate::core::index_progress::ProgressGuard::new(
535 project_root.to_string(),
536 crate::core::index_progress::IndexComponent::Semantic,
537 );
538
539 let bm25_idx = try_load_bm25_index(project_root);
540 match bm25_idx.as_ref() {
541 Some(idx) if idx.doc_count > 0 => {
542 let outcome = crate::core::embedding_index::build_or_update(root, idx);
543 match outcome {
544 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
545 crate::core::telemetry_features::record(
546 crate::core::telemetry_features::Feature::SemanticIndex,
547 true,
548 );
549 }
550 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
551 crate::core::telemetry_features::record(
552 crate::core::telemetry_features::Feature::SemanticIndex,
553 false,
554 );
555 }
556 _ => {}
557 }
558 let mut s = state
559 .lock()
560 .unwrap_or_else(std::sync::PoisonError::into_inner);
561 match outcome {
562 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
563 finish_ok(&mut s.semantic);
564 }
565 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
566 finish_ok(&mut s.semantic);
567 s.semantic.note = Some(
568 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
569 .to_string(),
570 );
571 }
572 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
573 ref reason,
574 ) => {
575 s.semantic.state = State::Idle;
578 s.semantic.note = Some(format!("embedding model not available: {reason}"));
579 }
580 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
581 finish_err(
582 &mut s.semantic,
583 "embedding build failed (see logs)".to_string(),
584 );
585 }
586 }
587 }
588 _ => {
589 let mut s = state
590 .lock()
591 .unwrap_or_else(std::sync::PoisonError::into_inner);
592 s.semantic.state = State::Idle;
594 s.semantic.note =
595 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
596 }
597 }
598}
599
600pub fn build_semantic_background(project_root: &str) {
605 let root = project_root.to_string();
606 let spawned = std::thread::Builder::new()
607 .name("leanctx-semantic".to_string())
608 .stack_size(INDEXER_STACK_BYTES)
609 .spawn(move || {
610 start_index_memory_guard("semantic_build");
611 if !disk_status_for_bm25(&root).exists {
612 ensure_all_background(&root);
613 }
614 while progress_view(&root).graph_bm25_active() {
615 std::thread::sleep(std::time::Duration::from_millis(250));
616 }
617 build_semantic(&root);
618 });
619 if let Err(e) = spawned {
620 tracing::warn!("[index_orchestrator: could not spawn semantic worker: {e}]");
621 }
622}
623
624fn start_index_memory_guard(label: &'static str) {
630 crate::core::memory_guard::start_guard(std::sync::Arc::new(move |level| {
631 tracing::warn!("[{label}] memory pressure: {level:?} — background tasks will throttle");
632 let reclaimed = crate::core::eviction_orchestrator::on_memory_pressure(level);
633 crate::core::memory_guard::force_purge();
634 reclaimed
635 }));
636}
637
638const MAX_EXTRA_ROOT_BUILDS: usize = 8;
642
643pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
644 let primary = Path::new(primary_root);
645 let mut queue: Vec<String> = Vec::new();
646 for root in extra_roots {
647 if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
648 break;
649 }
650 let rp = Path::new(root);
651 if !rp.is_dir() {
652 continue;
653 }
654 if rp.starts_with(primary) {
656 continue;
657 }
658 if primary.starts_with(rp) {
660 continue;
661 }
662 queue.push(root.clone());
663 }
664 if queue.is_empty() {
665 return;
666 }
667
668 let spawned = std::thread::Builder::new()
677 .name("leanctx-extra-roots".to_string())
678 .stack_size(INDEXER_STACK_BYTES)
679 .spawn(move || {
680 for root in queue {
681 if crate::core::memory_guard::is_under_pressure()
682 || crate::core::memory_guard::abort_requested()
683 {
684 tracing::warn!(
685 "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
686 );
687 break;
688 }
689 if !try_claim_worker(&root) {
690 continue; }
692 nudge_daemon_index(&root);
693 run_build_worker(&root);
694 }
695 });
696 if let Err(e) = spawned {
697 tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
698 }
699}
700
701fn bm25_build_note(
706 doc_count: usize,
707 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
708) -> String {
709 use crate::core::bm25_index::SaveOutcome;
710 match save {
711 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
712 "indexed {doc_count} chunks, {:.1} MB persisted",
713 *compressed_bytes as f64 / 1_048_576.0
714 ),
715 Ok(SaveOutcome::SkippedTooLarge {
716 compressed_bytes,
717 limit_bytes,
718 }) => format!(
719 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
720 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
721 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
722 *compressed_bytes as f64 / 1_048_576.0,
723 *limit_bytes as f64 / 1_048_576.0
724 ),
725 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
726 }
727}
728
729#[derive(Debug, Clone)]
732pub struct Bm25Summary {
733 pub state: &'static str,
734 pub elapsed_ms: Option<u64>,
736 pub note: Option<String>,
737 pub last_error: Option<String>,
738}
739
740#[derive(Debug, Clone)]
742pub struct SemanticSummary {
743 pub state: &'static str,
744 pub elapsed_ms: Option<u64>,
745 pub note: Option<String>,
746 pub last_error: Option<String>,
747}
748
749fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
753 let elapsed_ms = if matches!(c.state, State::Building) {
754 c.started_ms.map(|start| now_ms().saturating_sub(start))
755 } else {
756 c.duration_ms
757 };
758 let state = match c.state {
759 State::Idle => "idle",
760 State::Building => "building",
761 State::Ready => "ready",
762 State::Failed => "failed",
763 };
764 (state, elapsed_ms)
765}
766
767pub fn semantic_summary(project_root: &str) -> SemanticSummary {
768 let entry = entry_for(project_root);
769 let s = entry
770 .lock()
771 .unwrap_or_else(std::sync::PoisonError::into_inner);
772 let c = &s.semantic;
773 let (state, elapsed_ms) = component_elapsed_and_state(c);
774 SemanticSummary {
775 state,
776 elapsed_ms,
777 note: c.note.clone(),
778 last_error: c.last_error.clone(),
779 }
780}
781
782pub fn bm25_summary(project_root: &str) -> Bm25Summary {
783 let entry = entry_for(project_root);
784 let s = entry
785 .lock()
786 .unwrap_or_else(std::sync::PoisonError::into_inner);
787 let c = &s.bm25;
788 let (state, elapsed_ms) = component_elapsed_and_state(c);
789 Bm25Summary {
790 state,
791 elapsed_ms,
792 note: c.note.clone(),
793 last_error: c.last_error.clone(),
794 }
795}
796
797pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
798 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
801}
802
803pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
804 BM25Index::load(Path::new(project_root))
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
809pub struct SlotProgress {
810 pub building: bool,
811 pub done: u64,
812 pub total: u64,
813}
814
815impl SlotProgress {
816 pub fn is_determinate(&self) -> bool {
817 self.building && self.total > 0
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq)]
823pub struct ProgressView {
824 pub worker_running: bool,
825 pub graph_run_done: bool,
827 pub bm25_run_done: bool,
829 pub graph: SlotProgress,
830 pub bm25: SlotProgress,
831 pub semantic: SlotProgress,
832}
833
834impl ProgressView {
835 pub fn graph_bm25_active(&self) -> bool {
840 self.worker_running && !(self.graph_run_done && self.bm25_run_done)
841 }
842
843 pub fn semantic_active(&self) -> bool {
844 self.semantic.building
845 }
846}
847
848pub fn progress_view(project_root: &str) -> ProgressView {
850 use crate::core::index_progress::{self, IndexComponent};
851 let (worker_running, graph_run_done, bm25_run_done, graph_b, bm25_b, sem_b) = {
852 let state = entry_for(project_root);
853 let s = state
854 .lock()
855 .unwrap_or_else(std::sync::PoisonError::into_inner);
856 (
857 s.worker_running,
858 s.graph_run_done,
859 s.bm25_run_done,
860 matches!(s.graph.state, State::Building),
861 matches!(s.bm25.state, State::Building),
862 matches!(s.semantic.state, State::Building),
863 )
864 };
865 let g = index_progress::get(project_root, IndexComponent::Graph);
866 let b = index_progress::get(project_root, IndexComponent::Bm25);
867 let sem = index_progress::get(project_root, IndexComponent::Semantic);
868 ProgressView {
869 worker_running,
870 graph_run_done,
871 bm25_run_done,
872 graph: SlotProgress {
873 building: graph_b,
874 done: g.done,
875 total: g.total,
876 },
877 bm25: SlotProgress {
878 building: bm25_b,
879 done: b.done,
880 total: b.total,
881 },
882 semantic: SlotProgress {
883 building: sem_b,
884 done: sem.done,
885 total: sem.total,
886 },
887 }
888}
889
890pub fn is_building() -> bool {
892 let map = registry()
893 .lock()
894 .unwrap_or_else(std::sync::PoisonError::into_inner);
895 map.values().any(|entry| {
896 let st = entry
897 .lock()
898 .unwrap_or_else(std::sync::PoisonError::into_inner);
899 matches!(st.bm25.state, State::Building)
900 || matches!(st.graph.state, State::Building)
901 || matches!(st.semantic.state, State::Building)
902 })
903}
904
905#[derive(Debug, Serialize)]
906struct ComponentStatus<'a> {
907 state: &'a str,
908 started_ms: Option<u64>,
909 finished_ms: Option<u64>,
910 duration_ms: Option<u64>,
911 last_error: Option<&'a str>,
912 #[serde(skip_serializing_if = "Option::is_none")]
913 note: Option<&'a str>,
914 #[serde(skip_serializing_if = "Option::is_none")]
916 progress_done: Option<u64>,
917 #[serde(skip_serializing_if = "Option::is_none")]
919 progress_total: Option<u64>,
920}
921
922fn component_status(
923 c: &Component,
924 progress: crate::core::index_progress::ProgressSnapshot,
925) -> ComponentStatus<'_> {
926 let (progress_done, progress_total) = if matches!(c.state, State::Building) {
927 (Some(progress.done), Some(progress.total))
928 } else {
929 (None, None)
930 };
931 ComponentStatus {
932 state: match c.state {
933 State::Idle => "idle",
934 State::Building => "building",
935 State::Ready => "ready",
936 State::Failed => "failed",
937 },
938 started_ms: c.started_ms,
939 finished_ms: c.finished_ms,
940 duration_ms: c.duration_ms,
941 last_error: c.last_error.as_deref(),
942 note: c.note.as_deref(),
943 progress_done,
944 progress_total,
945 }
946}
947
948#[derive(Debug, Serialize)]
949struct StatusResponse<'a> {
950 project_root: &'a str,
951 graph_index: ComponentStatus<'a>,
952 bm25_index: ComponentStatus<'a>,
953 semantic_index: ComponentStatus<'a>,
957 disk: DiskStatusAll,
958 #[serde(skip_serializing_if = "Option::is_none")]
961 index_filters: Option<String>,
962}
963
964#[derive(Debug, Serialize, Default)]
965pub struct DiskStatus {
966 pub exists: bool,
967 pub size_bytes: Option<u64>,
968 pub file_count: Option<u64>,
969 pub modified_at: Option<String>,
970 #[serde(skip_serializing_if = "Option::is_none")]
974 pub partial_of: Option<u64>,
975}
976
977#[derive(Debug, Serialize, Default)]
978pub struct DiskStatusAll {
979 pub graph_index: DiskStatus,
980 pub bm25_index: DiskStatus,
981 pub code_graph: DiskStatus,
982 pub semantic_index: DiskStatus,
986}
987
988pub fn disk_status_for_graph(project_root: &str) -> DiskStatus {
993 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
999 return DiskStatus::default();
1000 };
1001 let meta_file = dir.join("graph.meta.json");
1002 if !meta_file.exists() {
1003 return DiskStatus::default();
1004 }
1005 let meta = std::fs::metadata(&meta_file).ok();
1006 let file_count = crate::core::property_graph::stored_counts(project_root)
1008 .map(|counts| counts.files)
1009 .filter(|files| *files > 0);
1010 DiskStatus {
1011 exists: true,
1012 size_bytes: None,
1013 file_count,
1014 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1015 partial_of: None,
1016 }
1017}
1018
1019pub fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
1022 let root = Path::new(project_root);
1023 let path = BM25Index::index_file_path(root);
1024 if !path.exists() {
1025 return DiskStatus::default();
1026 }
1027 let meta = std::fs::metadata(&path).ok();
1028 DiskStatus {
1029 exists: true,
1030 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1031 file_count: BM25Index::persisted_chunk_count(root),
1032 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1033 partial_of: None,
1034 }
1035}
1036
1037fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
1038 let dir = crate::core::property_graph::graph_dir(project_root);
1039 let db_path = dir.join("graph.db");
1040 if !db_path.exists() {
1041 return DiskStatus::default();
1042 }
1043 let meta = std::fs::metadata(&db_path).ok();
1044 let node_count =
1047 crate::core::property_graph::stored_counts(project_root).map(|counts| counts.nodes);
1048 DiskStatus {
1049 exists: true,
1050 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1051 file_count: node_count,
1052 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1053 partial_of: None,
1054 }
1055}
1056
1057fn format_time(t: SystemTime) -> String {
1058 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1059 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
1060 dt.map_or_else(
1061 || format!("{secs}"),
1062 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
1063 )
1064}
1065
1066pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
1067 let root = Path::new(project_root);
1068 let dir = crate::core::index_namespace::vectors_dir(root);
1069 let bin_path = dir.join("embeddings.bin");
1070 if !bin_path.exists() {
1071 return DiskStatus::default();
1072 }
1073 let meta = std::fs::metadata(&bin_path).ok();
1074 let partial = crate::core::embedding_index::partial_progress(root);
1075 DiskStatus {
1076 exists: true,
1077 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1078 file_count: partial.map(|(done, _)| done),
1079 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1080 partial_of: partial.map(|(_, total)| total),
1081 }
1082}
1083
1084pub fn disk_status(project_root: &str) -> DiskStatusAll {
1085 DiskStatusAll {
1086 graph_index: disk_status_for_graph(project_root),
1087 bm25_index: disk_status_for_bm25(project_root),
1088 code_graph: disk_status_for_code_graph(project_root),
1089 semantic_index: disk_status_for_semantic(project_root),
1090 }
1091}
1092
1093pub fn status_json(project_root: &str) -> String {
1094 let disk = disk_status(project_root);
1098 let state = entry_for(project_root);
1099 let s = state
1100 .lock()
1101 .unwrap_or_else(std::sync::PoisonError::into_inner);
1102 let graph_p = crate::core::index_progress::get(
1103 project_root,
1104 crate::core::index_progress::IndexComponent::Graph,
1105 );
1106 let bm25_p = crate::core::index_progress::get(
1107 project_root,
1108 crate::core::index_progress::IndexComponent::Bm25,
1109 );
1110 let semantic_p = crate::core::index_progress::get(
1111 project_root,
1112 crate::core::index_progress::IndexComponent::Semantic,
1113 );
1114 let res = StatusResponse {
1115 project_root,
1116 graph_index: component_status(&s.graph, graph_p),
1117 bm25_index: component_status(&s.bm25, bm25_p),
1118 semantic_index: component_status(&s.semantic, semantic_p),
1119 disk,
1120 index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
1121 };
1122 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128
1129 #[test]
1130 fn progress_view_graph_bm25_active_logic() {
1131 let idle = SlotProgress::default();
1132 let mut v = ProgressView {
1133 worker_running: true,
1134 graph_run_done: false,
1135 bm25_run_done: false,
1136 graph: idle,
1137 bm25: idle,
1138 semantic: idle,
1139 };
1140 assert!(v.graph_bm25_active(), "claimed, phases not done");
1141 v.graph_run_done = true;
1142 assert!(v.graph_bm25_active(), "graph done, bm25 pending");
1143 v.bm25_run_done = true;
1144 assert!(
1145 !v.graph_bm25_active(),
1146 "both phases done — search pre-warm must not keep wait open"
1147 );
1148 v.worker_running = false;
1149 assert!(!v.graph_bm25_active());
1150 v.graph_run_done = true;
1152 v.bm25_run_done = true;
1153 assert!(!v.graph_bm25_active());
1154 }
1155
1156 #[test]
1159 fn worker_slot_is_released_when_the_worker_panics() {
1160 let root = "/tmp/leanctx-worker-slot-panic-test";
1161 assert!(try_claim_worker(root));
1162 assert!(progress_view(root).graph_bm25_active());
1163 let result = std::panic::catch_unwind(|| {
1164 let _slot = WorkerSlot(root.to_string());
1165 panic!("simulated failure outside the phase catch_unwind");
1166 });
1167 assert!(result.is_err());
1168 let view = progress_view(root);
1169 assert!(!view.worker_running, "slot released by the guard");
1170 assert!(!view.graph_bm25_active(), "the CLI wait loop ends");
1171 assert!(try_claim_worker(root), "a later build can claim it again");
1172 drop(WorkerSlot(root.to_string()));
1173 }
1174
1175 #[test]
1176 fn status_json_is_valid_json() {
1177 let s = status_json("/tmp");
1178 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
1179 }
1180
1181 #[test]
1182 fn background_build_phases_are_serialized_with_reclamation() {
1183 let events = std::cell::RefCell::new(Vec::new());
1184 run_memory_bounded_phases(
1185 || events.borrow_mut().push("graph"),
1186 || events.borrow_mut().push("bm25"),
1187 || events.borrow_mut().push("search"),
1188 || events.borrow_mut().push("reclaim"),
1189 );
1190 assert_eq!(
1191 events.into_inner(),
1192 ["graph", "reclaim", "bm25", "reclaim", "search"]
1193 );
1194 }
1195
1196 #[test]
1197 fn warm_need_classifies_tools() {
1198 for light in [
1200 "ctx_read",
1201 "ctx_shell",
1202 "ctx_tree",
1203 "ctx_knowledge",
1204 "unknown_tool",
1205 ] {
1206 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
1207 }
1208 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
1210 for heavy in [
1211 "ctx_graph",
1212 "ctx_callgraph",
1213 "ctx_artifacts",
1214 "ctx_provider",
1215 "ctx_compose",
1216 ] {
1217 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
1218 }
1219 }
1220
1221 #[test]
1222 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
1223 assert!(!ensure_warm_for_tool("", "ctx_graph"));
1224 let tmp = tempfile::tempdir().unwrap();
1225 let root = tmp.path().to_string_lossy().to_string();
1226 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
1227 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
1228 }
1229
1230 #[test]
1231 fn ensure_warm_heavy_is_once_per_root() {
1232 let tmp = tempfile::tempdir().unwrap();
1236 let root = tmp.path().to_string_lossy().to_string();
1237 assert!(
1238 ensure_warm_for_tool(&root, "ctx_callgraph"),
1239 "first heavy warm must signal true"
1240 );
1241 assert!(
1242 !ensure_warm_for_tool(&root, "ctx_callgraph"),
1243 "second heavy warm must be deduped to false"
1244 );
1245 assert!(
1246 !ensure_warm_for_tool(&root, "ctx_compose"),
1247 "any later heavy tool on the same root is also deduped"
1248 );
1249 }
1250
1251 #[test]
1252 fn build_note_persisted_reports_size() {
1253 let note = bm25_build_note(
1254 42,
1255 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
1256 compressed_bytes: 3 * 1024 * 1024,
1257 }),
1258 );
1259 assert!(
1260 note.contains("42 chunks"),
1261 "note should report chunk count: {note}"
1262 );
1263 assert!(
1264 note.contains("persisted"),
1265 "note should report persistence: {note}"
1266 );
1267 }
1268
1269 #[test]
1270 fn build_note_too_large_carries_remedy() {
1271 let note = bm25_build_note(
1272 1000,
1273 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
1274 compressed_bytes: 600 * 1024 * 1024,
1275 limit_bytes: 512 * 1024 * 1024,
1276 }),
1277 );
1278 assert!(
1279 note.contains("NOT persisted"),
1280 "must flag non-persistence: {note}"
1281 );
1282 assert!(
1283 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
1284 "too-large note must carry an actionable remedy: {note}"
1285 );
1286 }
1287
1288 #[test]
1289 fn build_note_persist_error_is_reported() {
1290 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
1291 assert!(note.contains("persisting failed"), "note: {note}");
1292 assert!(
1293 note.contains("disk full"),
1294 "note should include the io error: {note}"
1295 );
1296 }
1297
1298 #[test]
1299 fn bm25_summary_unknown_project_is_idle() {
1300 let tmp = tempfile::tempdir().unwrap();
1301 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
1302 assert_eq!(summary.state, "idle");
1303 assert!(summary.note.is_none());
1304 assert!(summary.last_error.is_none());
1305 }
1306
1307 #[test]
1308 fn extra_roots_skips_subdirs_of_primary() {
1309 let tmp = tempfile::tempdir().unwrap();
1310 let primary = tmp.path().join("primary");
1311 std::fs::create_dir_all(&primary).unwrap();
1312 let sub = primary.join("subdir");
1313 std::fs::create_dir_all(&sub).unwrap();
1314 let external = tmp.path().join("external");
1315 std::fs::create_dir_all(&external).unwrap();
1316
1317 let primary_str = primary.to_string_lossy().to_string();
1318 let extra = vec![
1319 sub.to_string_lossy().to_string(),
1320 external.to_string_lossy().to_string(),
1321 ];
1322
1323 ensure_extra_roots_background(&primary_str, &extra);
1325 }
1326
1327 #[test]
1328 fn extra_roots_caps_at_max() {
1329 let tmp = tempfile::tempdir().unwrap();
1330 let primary = tmp.path().join("primary");
1331 std::fs::create_dir_all(&primary).unwrap();
1332
1333 let mut extra = Vec::new();
1334 for i in 0..20 {
1335 let d = tmp.path().join(format!("ext-{i}"));
1336 std::fs::create_dir_all(&d).unwrap();
1337 extra.push(d.to_string_lossy().to_string());
1338 }
1339
1340 let primary_str = primary.to_string_lossy().to_string();
1341 ensure_extra_roots_background(&primary_str, &extra);
1343 }
1344
1345 #[test]
1346 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1347 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1348 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1349 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1350 assert_ne!(a, b, "lock name must be per-repo");
1351 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1353 let graph = format!(
1356 "graph-idx-{}",
1357 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1358 );
1359 assert_ne!(a, graph, "bm25 and graph locks must be independent");
1360 }
1361}