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 crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
329 std::cell::RefCell::new(Some(std::path::PathBuf::from(root))),
330 || run_build_worker_in_project(root),
331 );
332}
333
334fn run_build_worker_in_project(root: &str) {
335 let _slot = WorkerSlot(root.to_string());
339
340 start_index_memory_guard("build_worker");
347
348 let graph_state = entry_for(root);
354 let graph_root = root.to_string();
355 let build_graph = move || {
356 {
357 let mut s = graph_state
358 .lock()
359 .unwrap_or_else(std::sync::PoisonError::into_inner);
360 start_component(&mut s.graph);
361 }
362 let guard = crate::core::index_progress::ProgressGuard::new(
365 graph_root.clone(),
366 crate::core::index_progress::IndexComponent::Graph,
367 );
368 guard.report(0, 0);
369 let graph_result = std::panic::catch_unwind(|| {
370 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
371 crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
375 });
376 if graph_result.is_ok() {
377 crate::core::call_graph::CallGraph::invalidate(&graph_root);
380 let _ = std::panic::catch_unwind(|| {
383 crate::core::graph_enricher::refresh_semantic_edges_in_background(&graph_root);
384 });
385 }
386 drop(guard);
387 crate::core::telemetry_features::record(
388 crate::core::telemetry_features::Feature::GraphIndex,
389 graph_result.is_ok(),
390 );
391 {
392 let mut s = graph_state
393 .lock()
394 .unwrap_or_else(std::sync::PoisonError::into_inner);
395 if let Ok(()) = graph_result {
396 finish_ok(&mut s.graph);
397 } else {
398 finish_err(&mut s.graph, "graph index build panicked".to_string());
399 }
400 s.graph_run_done = true;
401 }
402 };
403
404 let bm25_state = entry_for(root);
405 let bm25_root = root.to_string();
406 let build_bm25 = move || {
407 {
408 let mut s = bm25_state
409 .lock()
410 .unwrap_or_else(std::sync::PoisonError::into_inner);
411 start_component(&mut s.bm25);
412 }
413 let _progress = crate::core::index_progress::ProgressGuard::new(
414 bm25_root.clone(),
415 crate::core::index_progress::IndexComponent::Bm25,
416 );
417 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
418 let root_pb = Path::new(&bm25_root);
419 let lock_name = bm25_index_lock_name(root_pb);
420 let _lock = crate::core::startup_guard::try_acquire_lock(
421 &lock_name,
422 std::time::Duration::from_millis(800),
423 std::time::Duration::from_mins(3),
424 );
425 if _lock.is_none() {
426 tracing::info!(
427 "[bm25: another process is building {bm25_root} — loading the shared index]"
428 );
429 let idx = BM25Index::load(root_pb).unwrap_or_default();
430 return (idx.doc_count, None);
431 }
432 let idx = BM25Index::load_or_build(root_pb);
433 let outcome = idx.save(root_pb);
434 (idx.doc_count, Some(outcome))
435 }));
436 if !matches!(bm, Ok((_, None))) {
438 crate::core::telemetry_features::record(
439 crate::core::telemetry_features::Feature::Bm25Index,
440 bm.is_ok(),
441 );
442 }
443 {
444 let mut s = bm25_state
445 .lock()
446 .unwrap_or_else(std::sync::PoisonError::into_inner);
447 if let Ok((doc_count, save_res)) = bm {
448 finish_ok(&mut s.bm25);
449 s.bm25.note = Some(match save_res {
450 Some(outcome) => bm25_build_note(doc_count, &outcome),
451 None => format!(
452 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
453 ),
454 });
455 } else {
456 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
457 }
458 s.bm25_run_done = true;
459 }
460 };
461
462 run_memory_bounded_phases(
463 build_graph,
464 build_bm25,
465 || crate::core::search_index::ensure_background(root, true, false),
466 || {
467 crate::core::content_cache::clear();
468 crate::core::memory_guard::force_purge();
469 },
470 );
471
472 crate::core::content_cache::trim_oldest_percent(75);
479 crate::core::memory_guard::force_purge();
480}
481
482struct WorkerSlot(String);
485
486impl Drop for WorkerSlot {
487 fn drop(&mut self) {
488 let state = entry_for(&self.0);
489 let mut s = state
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner);
492 s.worker_running = false;
493 }
494}
495
496fn run_memory_bounded_phases<G, B, S, R>(graph: G, bm25: B, search: S, mut reclaim: R)
499where
500 G: FnOnce(),
501 B: FnOnce(),
502 S: FnOnce(),
503 R: FnMut(),
504{
505 graph();
506 reclaim();
507 bm25();
508 reclaim();
509 search();
510}
511
512pub fn build_semantic(project_root: &str) {
517 let state = entry_for(project_root);
518 let root = Path::new(project_root);
519
520 let Some(_lock) = crate::core::startup_guard::try_acquire_lock(
521 &semantic_index_lock_name(root),
522 std::time::Duration::ZERO,
523 SEMANTIC_LOCK_STALE_AFTER,
524 ) else {
525 let mut s = state
526 .lock()
527 .unwrap_or_else(std::sync::PoisonError::into_inner);
528 s.semantic.note = Some(
529 "another lean-ctx process is already building the semantic index for this project; \
530 it saves progress as it goes — check `lean-ctx index status` later"
531 .to_string(),
532 );
533 return;
534 };
535
536 {
537 let mut s = state
538 .lock()
539 .unwrap_or_else(std::sync::PoisonError::into_inner);
540 start_component(&mut s.semantic);
541 }
542
543 let _progress = crate::core::index_progress::ProgressGuard::new(
545 project_root.to_string(),
546 crate::core::index_progress::IndexComponent::Semantic,
547 );
548
549 let bm25_idx = try_load_bm25_index(project_root);
550 match bm25_idx.as_ref() {
551 Some(idx) if idx.doc_count > 0 => {
552 let outcome = crate::core::embedding_index::build_or_update(root, idx);
553 match outcome {
554 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
555 crate::core::telemetry_features::record(
556 crate::core::telemetry_features::Feature::SemanticIndex,
557 true,
558 );
559 }
560 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
561 crate::core::telemetry_features::record(
562 crate::core::telemetry_features::Feature::SemanticIndex,
563 false,
564 );
565 }
566 _ => {}
567 }
568 let mut s = state
569 .lock()
570 .unwrap_or_else(std::sync::PoisonError::into_inner);
571 match outcome {
572 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
573 finish_ok(&mut s.semantic);
574 }
575 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
576 finish_ok(&mut s.semantic);
577 s.semantic.note = Some(
578 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
579 .to_string(),
580 );
581 }
582 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
583 ref reason,
584 ) => {
585 s.semantic.state = State::Idle;
588 s.semantic.note = Some(format!("embedding model not available: {reason}"));
589 }
590 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
591 finish_err(
592 &mut s.semantic,
593 "embedding build failed (see logs)".to_string(),
594 );
595 }
596 }
597 }
598 _ => {
599 let mut s = state
600 .lock()
601 .unwrap_or_else(std::sync::PoisonError::into_inner);
602 s.semantic.state = State::Idle;
604 s.semantic.note =
605 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
606 }
607 }
608}
609
610pub fn build_semantic_background(project_root: &str) {
615 let root = project_root.to_string();
616 let spawned = std::thread::Builder::new()
617 .name("leanctx-semantic".to_string())
618 .stack_size(INDEXER_STACK_BYTES)
619 .spawn(move || {
620 start_index_memory_guard("semantic_build");
621 if !disk_status_for_bm25(&root).exists {
622 ensure_all_background(&root);
623 }
624 while progress_view(&root).graph_bm25_active() {
625 std::thread::sleep(std::time::Duration::from_millis(250));
626 }
627 build_semantic(&root);
628 });
629 if let Err(e) = spawned {
630 tracing::warn!("[index_orchestrator: could not spawn semantic worker: {e}]");
631 }
632}
633
634fn start_index_memory_guard(label: &'static str) {
640 crate::core::memory_guard::start_guard(std::sync::Arc::new(move |level| {
641 tracing::warn!("[{label}] memory pressure: {level:?} — background tasks will throttle");
642 let reclaimed = crate::core::eviction_orchestrator::on_memory_pressure(level);
643 crate::core::memory_guard::force_purge();
644 reclaimed
645 }));
646}
647
648const MAX_EXTRA_ROOT_BUILDS: usize = 8;
652
653pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
654 let primary = Path::new(primary_root);
655 let mut queue: Vec<String> = Vec::new();
656 for root in extra_roots {
657 if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
658 break;
659 }
660 let rp = Path::new(root);
661 if !rp.is_dir() {
662 continue;
663 }
664 if rp.starts_with(primary) {
666 continue;
667 }
668 if primary.starts_with(rp) {
670 continue;
671 }
672 queue.push(root.clone());
673 }
674 if queue.is_empty() {
675 return;
676 }
677
678 let spawned = std::thread::Builder::new()
687 .name("leanctx-extra-roots".to_string())
688 .stack_size(INDEXER_STACK_BYTES)
689 .spawn(move || {
690 for root in queue {
691 if crate::core::memory_guard::is_under_pressure()
692 || crate::core::memory_guard::abort_requested()
693 {
694 tracing::warn!(
695 "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
696 );
697 break;
698 }
699 if !try_claim_worker(&root) {
700 continue; }
702 nudge_daemon_index(&root);
703 run_build_worker(&root);
704 }
705 });
706 if let Err(e) = spawned {
707 tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
708 }
709}
710
711fn bm25_build_note(
716 doc_count: usize,
717 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
718) -> String {
719 use crate::core::bm25_index::SaveOutcome;
720 match save {
721 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
722 "indexed {doc_count} chunks, {:.1} MB persisted",
723 *compressed_bytes as f64 / 1_048_576.0
724 ),
725 Ok(SaveOutcome::SkippedTooLarge {
726 compressed_bytes,
727 limit_bytes,
728 }) => format!(
729 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
730 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
731 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
732 *compressed_bytes as f64 / 1_048_576.0,
733 *limit_bytes as f64 / 1_048_576.0
734 ),
735 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
736 }
737}
738
739#[derive(Debug, Clone)]
742pub struct Bm25Summary {
743 pub state: &'static str,
744 pub elapsed_ms: Option<u64>,
746 pub note: Option<String>,
747 pub last_error: Option<String>,
748}
749
750#[derive(Debug, Clone)]
752pub struct SemanticSummary {
753 pub state: &'static str,
754 pub elapsed_ms: Option<u64>,
755 pub note: Option<String>,
756 pub last_error: Option<String>,
757}
758
759fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
763 let elapsed_ms = if matches!(c.state, State::Building) {
764 c.started_ms.map(|start| now_ms().saturating_sub(start))
765 } else {
766 c.duration_ms
767 };
768 let state = match c.state {
769 State::Idle => "idle",
770 State::Building => "building",
771 State::Ready => "ready",
772 State::Failed => "failed",
773 };
774 (state, elapsed_ms)
775}
776
777pub fn semantic_summary(project_root: &str) -> SemanticSummary {
778 let entry = entry_for(project_root);
779 let s = entry
780 .lock()
781 .unwrap_or_else(std::sync::PoisonError::into_inner);
782 let c = &s.semantic;
783 let (state, elapsed_ms) = component_elapsed_and_state(c);
784 SemanticSummary {
785 state,
786 elapsed_ms,
787 note: c.note.clone(),
788 last_error: c.last_error.clone(),
789 }
790}
791
792pub fn bm25_summary(project_root: &str) -> Bm25Summary {
793 let entry = entry_for(project_root);
794 let s = entry
795 .lock()
796 .unwrap_or_else(std::sync::PoisonError::into_inner);
797 let c = &s.bm25;
798 let (state, elapsed_ms) = component_elapsed_and_state(c);
799 Bm25Summary {
800 state,
801 elapsed_ms,
802 note: c.note.clone(),
803 last_error: c.last_error.clone(),
804 }
805}
806
807pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
808 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
811}
812
813pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
814 BM25Index::load(Path::new(project_root))
815}
816
817#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
819pub struct SlotProgress {
820 pub building: bool,
821 pub done: u64,
822 pub total: u64,
823}
824
825impl SlotProgress {
826 pub fn is_determinate(&self) -> bool {
827 self.building && self.total > 0
828 }
829}
830
831#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct ProgressView {
834 pub worker_running: bool,
835 pub graph_run_done: bool,
837 pub bm25_run_done: bool,
839 pub graph: SlotProgress,
840 pub bm25: SlotProgress,
841 pub semantic: SlotProgress,
842}
843
844impl ProgressView {
845 pub fn graph_bm25_active(&self) -> bool {
850 self.worker_running && !(self.graph_run_done && self.bm25_run_done)
851 }
852
853 pub fn semantic_active(&self) -> bool {
854 self.semantic.building
855 }
856}
857
858pub fn progress_view(project_root: &str) -> ProgressView {
860 use crate::core::index_progress::{self, IndexComponent};
861 let (worker_running, graph_run_done, bm25_run_done, graph_b, bm25_b, sem_b) = {
862 let state = entry_for(project_root);
863 let s = state
864 .lock()
865 .unwrap_or_else(std::sync::PoisonError::into_inner);
866 (
867 s.worker_running,
868 s.graph_run_done,
869 s.bm25_run_done,
870 matches!(s.graph.state, State::Building),
871 matches!(s.bm25.state, State::Building),
872 matches!(s.semantic.state, State::Building),
873 )
874 };
875 let g = index_progress::get(project_root, IndexComponent::Graph);
876 let b = index_progress::get(project_root, IndexComponent::Bm25);
877 let sem = index_progress::get(project_root, IndexComponent::Semantic);
878 ProgressView {
879 worker_running,
880 graph_run_done,
881 bm25_run_done,
882 graph: SlotProgress {
883 building: graph_b,
884 done: g.done,
885 total: g.total,
886 },
887 bm25: SlotProgress {
888 building: bm25_b,
889 done: b.done,
890 total: b.total,
891 },
892 semantic: SlotProgress {
893 building: sem_b,
894 done: sem.done,
895 total: sem.total,
896 },
897 }
898}
899
900pub fn is_building() -> bool {
902 let map = registry()
903 .lock()
904 .unwrap_or_else(std::sync::PoisonError::into_inner);
905 map.values().any(|entry| {
906 let st = entry
907 .lock()
908 .unwrap_or_else(std::sync::PoisonError::into_inner);
909 matches!(st.bm25.state, State::Building)
910 || matches!(st.graph.state, State::Building)
911 || matches!(st.semantic.state, State::Building)
912 })
913}
914
915#[derive(Debug, Serialize)]
916struct ComponentStatus<'a> {
917 state: &'a str,
918 started_ms: Option<u64>,
919 finished_ms: Option<u64>,
920 duration_ms: Option<u64>,
921 last_error: Option<&'a str>,
922 #[serde(skip_serializing_if = "Option::is_none")]
923 note: Option<&'a str>,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 progress_done: Option<u64>,
927 #[serde(skip_serializing_if = "Option::is_none")]
929 progress_total: Option<u64>,
930}
931
932fn component_status(
933 c: &Component,
934 progress: crate::core::index_progress::ProgressSnapshot,
935) -> ComponentStatus<'_> {
936 let (progress_done, progress_total) = if matches!(c.state, State::Building) {
937 (Some(progress.done), Some(progress.total))
938 } else {
939 (None, None)
940 };
941 ComponentStatus {
942 state: match c.state {
943 State::Idle => "idle",
944 State::Building => "building",
945 State::Ready => "ready",
946 State::Failed => "failed",
947 },
948 started_ms: c.started_ms,
949 finished_ms: c.finished_ms,
950 duration_ms: c.duration_ms,
951 last_error: c.last_error.as_deref(),
952 note: c.note.as_deref(),
953 progress_done,
954 progress_total,
955 }
956}
957
958#[derive(Debug, Serialize)]
959struct StatusResponse<'a> {
960 project_root: &'a str,
961 graph_index: ComponentStatus<'a>,
962 bm25_index: ComponentStatus<'a>,
963 semantic_index: ComponentStatus<'a>,
967 disk: DiskStatusAll,
968 #[serde(skip_serializing_if = "Option::is_none")]
971 index_filters: Option<String>,
972}
973
974#[derive(Debug, Serialize, Default)]
975pub struct DiskStatus {
976 pub exists: bool,
977 pub size_bytes: Option<u64>,
978 pub file_count: Option<u64>,
979 pub modified_at: Option<String>,
980 #[serde(skip_serializing_if = "Option::is_none")]
984 pub partial_of: Option<u64>,
985}
986
987#[derive(Debug, Serialize, Default)]
988pub struct DiskStatusAll {
989 pub graph_index: DiskStatus,
990 pub bm25_index: DiskStatus,
991 pub code_graph: DiskStatus,
992 pub semantic_index: DiskStatus,
996}
997
998pub fn disk_status_for_graph(project_root: &str) -> DiskStatus {
1003 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
1009 return DiskStatus::default();
1010 };
1011 let meta_file = dir.join("graph.meta.json");
1012 if !meta_file.exists() {
1013 return DiskStatus::default();
1014 }
1015 let meta = std::fs::metadata(&meta_file).ok();
1016 let file_count = crate::core::property_graph::stored_counts(project_root)
1018 .map(|counts| counts.files)
1019 .filter(|files| *files > 0);
1020 DiskStatus {
1021 exists: true,
1022 size_bytes: None,
1023 file_count,
1024 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1025 partial_of: None,
1026 }
1027}
1028
1029pub fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
1032 let root = Path::new(project_root);
1033 let path = BM25Index::index_file_path(root);
1034 if !path.exists() {
1035 return DiskStatus::default();
1036 }
1037 let meta = std::fs::metadata(&path).ok();
1038 DiskStatus {
1039 exists: true,
1040 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1041 file_count: BM25Index::persisted_chunk_count(root),
1042 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1043 partial_of: None,
1044 }
1045}
1046
1047fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
1048 let dir = crate::core::property_graph::graph_dir(project_root);
1049 let db_path = dir.join("graph.db");
1050 if !db_path.exists() {
1051 return DiskStatus::default();
1052 }
1053 let meta = std::fs::metadata(&db_path).ok();
1054 let node_count =
1057 crate::core::property_graph::stored_counts(project_root).map(|counts| counts.nodes);
1058 DiskStatus {
1059 exists: true,
1060 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1061 file_count: node_count,
1062 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1063 partial_of: None,
1064 }
1065}
1066
1067fn format_time(t: SystemTime) -> String {
1068 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1069 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
1070 dt.map_or_else(
1071 || format!("{secs}"),
1072 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
1073 )
1074}
1075
1076pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
1077 let root = Path::new(project_root);
1078 let dir = crate::core::index_namespace::vectors_dir(root);
1079 let bin_path = dir.join("embeddings.bin");
1080 if !bin_path.exists() {
1081 return DiskStatus::default();
1082 }
1083 let meta = std::fs::metadata(&bin_path).ok();
1084 let partial = crate::core::embedding_index::partial_progress(root);
1085 DiskStatus {
1086 exists: true,
1087 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1088 file_count: partial.map(|(done, _)| done),
1089 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1090 partial_of: partial.map(|(_, total)| total),
1091 }
1092}
1093
1094pub fn disk_status(project_root: &str) -> DiskStatusAll {
1095 DiskStatusAll {
1096 graph_index: disk_status_for_graph(project_root),
1097 bm25_index: disk_status_for_bm25(project_root),
1098 code_graph: disk_status_for_code_graph(project_root),
1099 semantic_index: disk_status_for_semantic(project_root),
1100 }
1101}
1102
1103pub fn status_json(project_root: &str) -> String {
1104 let disk = disk_status(project_root);
1108 let state = entry_for(project_root);
1109 let s = state
1110 .lock()
1111 .unwrap_or_else(std::sync::PoisonError::into_inner);
1112 let graph_p = crate::core::index_progress::get(
1113 project_root,
1114 crate::core::index_progress::IndexComponent::Graph,
1115 );
1116 let bm25_p = crate::core::index_progress::get(
1117 project_root,
1118 crate::core::index_progress::IndexComponent::Bm25,
1119 );
1120 let semantic_p = crate::core::index_progress::get(
1121 project_root,
1122 crate::core::index_progress::IndexComponent::Semantic,
1123 );
1124 let res = StatusResponse {
1125 project_root,
1126 graph_index: component_status(&s.graph, graph_p),
1127 bm25_index: component_status(&s.bm25, bm25_p),
1128 semantic_index: component_status(&s.semantic, semantic_p),
1129 disk,
1130 index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
1131 };
1132 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::*;
1138
1139 #[test]
1140 fn progress_view_graph_bm25_active_logic() {
1141 let idle = SlotProgress::default();
1142 let mut v = ProgressView {
1143 worker_running: true,
1144 graph_run_done: false,
1145 bm25_run_done: false,
1146 graph: idle,
1147 bm25: idle,
1148 semantic: idle,
1149 };
1150 assert!(v.graph_bm25_active(), "claimed, phases not done");
1151 v.graph_run_done = true;
1152 assert!(v.graph_bm25_active(), "graph done, bm25 pending");
1153 v.bm25_run_done = true;
1154 assert!(
1155 !v.graph_bm25_active(),
1156 "both phases done — search pre-warm must not keep wait open"
1157 );
1158 v.worker_running = false;
1159 assert!(!v.graph_bm25_active());
1160 v.graph_run_done = true;
1162 v.bm25_run_done = true;
1163 assert!(!v.graph_bm25_active());
1164 }
1165
1166 #[test]
1169 fn worker_slot_is_released_when_the_worker_panics() {
1170 let root = "/tmp/leanctx-worker-slot-panic-test";
1171 assert!(try_claim_worker(root));
1172 assert!(progress_view(root).graph_bm25_active());
1173 let result = std::panic::catch_unwind(|| {
1174 let _slot = WorkerSlot(root.to_string());
1175 panic!("simulated failure outside the phase catch_unwind");
1176 });
1177 assert!(result.is_err());
1178 let view = progress_view(root);
1179 assert!(!view.worker_running, "slot released by the guard");
1180 assert!(!view.graph_bm25_active(), "the CLI wait loop ends");
1181 assert!(try_claim_worker(root), "a later build can claim it again");
1182 drop(WorkerSlot(root.to_string()));
1183 }
1184
1185 #[test]
1186 fn status_json_is_valid_json() {
1187 let s = status_json("/tmp");
1188 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
1189 }
1190
1191 #[test]
1192 fn background_build_phases_are_serialized_with_reclamation() {
1193 let events = std::cell::RefCell::new(Vec::new());
1194 run_memory_bounded_phases(
1195 || events.borrow_mut().push("graph"),
1196 || events.borrow_mut().push("bm25"),
1197 || events.borrow_mut().push("search"),
1198 || events.borrow_mut().push("reclaim"),
1199 );
1200 assert_eq!(
1201 events.into_inner(),
1202 ["graph", "reclaim", "bm25", "reclaim", "search"]
1203 );
1204 }
1205
1206 #[test]
1207 fn warm_need_classifies_tools() {
1208 for light in [
1210 "ctx_read",
1211 "ctx_shell",
1212 "ctx_tree",
1213 "ctx_knowledge",
1214 "unknown_tool",
1215 ] {
1216 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
1217 }
1218 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
1220 for heavy in [
1221 "ctx_graph",
1222 "ctx_callgraph",
1223 "ctx_artifacts",
1224 "ctx_provider",
1225 "ctx_compose",
1226 ] {
1227 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
1228 }
1229 }
1230
1231 #[test]
1232 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
1233 assert!(!ensure_warm_for_tool("", "ctx_graph"));
1234 let tmp = tempfile::tempdir().unwrap();
1235 let root = tmp.path().to_string_lossy().to_string();
1236 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
1237 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
1238 }
1239
1240 #[test]
1241 fn ensure_warm_heavy_is_once_per_root() {
1242 let tmp = tempfile::tempdir().unwrap();
1246 let root = tmp.path().to_string_lossy().to_string();
1247 assert!(
1248 ensure_warm_for_tool(&root, "ctx_callgraph"),
1249 "first heavy warm must signal true"
1250 );
1251 assert!(
1252 !ensure_warm_for_tool(&root, "ctx_callgraph"),
1253 "second heavy warm must be deduped to false"
1254 );
1255 assert!(
1256 !ensure_warm_for_tool(&root, "ctx_compose"),
1257 "any later heavy tool on the same root is also deduped"
1258 );
1259 }
1260
1261 #[test]
1262 fn build_note_persisted_reports_size() {
1263 let note = bm25_build_note(
1264 42,
1265 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
1266 compressed_bytes: 3 * 1024 * 1024,
1267 }),
1268 );
1269 assert!(
1270 note.contains("42 chunks"),
1271 "note should report chunk count: {note}"
1272 );
1273 assert!(
1274 note.contains("persisted"),
1275 "note should report persistence: {note}"
1276 );
1277 }
1278
1279 #[test]
1280 fn build_note_too_large_carries_remedy() {
1281 let note = bm25_build_note(
1282 1000,
1283 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
1284 compressed_bytes: 600 * 1024 * 1024,
1285 limit_bytes: 512 * 1024 * 1024,
1286 }),
1287 );
1288 assert!(
1289 note.contains("NOT persisted"),
1290 "must flag non-persistence: {note}"
1291 );
1292 assert!(
1293 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
1294 "too-large note must carry an actionable remedy: {note}"
1295 );
1296 }
1297
1298 #[test]
1299 fn build_note_persist_error_is_reported() {
1300 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
1301 assert!(note.contains("persisting failed"), "note: {note}");
1302 assert!(
1303 note.contains("disk full"),
1304 "note should include the io error: {note}"
1305 );
1306 }
1307
1308 #[test]
1309 fn bm25_summary_unknown_project_is_idle() {
1310 let tmp = tempfile::tempdir().unwrap();
1311 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
1312 assert_eq!(summary.state, "idle");
1313 assert!(summary.note.is_none());
1314 assert!(summary.last_error.is_none());
1315 }
1316
1317 #[test]
1318 fn extra_roots_skips_subdirs_of_primary() {
1319 let tmp = tempfile::tempdir().unwrap();
1320 let primary = tmp.path().join("primary");
1321 std::fs::create_dir_all(&primary).unwrap();
1322 let sub = primary.join("subdir");
1323 std::fs::create_dir_all(&sub).unwrap();
1324 let external = tmp.path().join("external");
1325 std::fs::create_dir_all(&external).unwrap();
1326
1327 let primary_str = primary.to_string_lossy().to_string();
1328 let extra = vec![
1329 sub.to_string_lossy().to_string(),
1330 external.to_string_lossy().to_string(),
1331 ];
1332
1333 ensure_extra_roots_background(&primary_str, &extra);
1335 }
1336
1337 #[test]
1338 fn extra_roots_caps_at_max() {
1339 let tmp = tempfile::tempdir().unwrap();
1340 let primary = tmp.path().join("primary");
1341 std::fs::create_dir_all(&primary).unwrap();
1342
1343 let mut extra = Vec::new();
1344 for i in 0..20 {
1345 let d = tmp.path().join(format!("ext-{i}"));
1346 std::fs::create_dir_all(&d).unwrap();
1347 extra.push(d.to_string_lossy().to_string());
1348 }
1349
1350 let primary_str = primary.to_string_lossy().to_string();
1351 ensure_extra_roots_background(&primary_str, &extra);
1353 }
1354
1355 #[test]
1356 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1357 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1358 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1359 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1360 assert_ne!(a, b, "lock name must be per-repo");
1361 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1363 let graph = format!(
1366 "graph-idx-{}",
1367 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1368 );
1369 assert_ne!(a, graph, "bm25 and graph locks must be independent");
1370 }
1371}