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 start_component(c: &mut Component) {
121 c.state = State::Building;
122 c.started_ms = Some(now_ms());
123 c.finished_ms = None;
124 c.duration_ms = None;
125 c.last_error = None;
126 c.note = None;
127}
128
129fn finish_ok(c: &mut Component) {
130 c.state = State::Ready;
131 let end = now_ms();
132 c.finished_ms = Some(end);
133 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
134}
135
136fn finish_err(c: &mut Component, e: String) {
137 c.state = State::Failed;
138 let end = now_ms();
139 c.finished_ms = Some(end);
140 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
141 c.last_error = Some(e);
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum WarmNeed {
150 None,
152 Search,
154 Heavy,
156}
157
158#[must_use]
162pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
163 match tool {
164 "ctx_search" => WarmNeed::Search,
165 "ctx_graph"
167 | "ctx_callgraph"
168 | "ctx_routes"
169 | "ctx_repomap"
170 | "ctx_impact"
171 | "ctx_artifacts"
172 | "ctx_semantic_search"
173 | "ctx_provider"
174 | "ctx_compose"
175 | "ctx_explore"
176 | "ctx_review" => WarmNeed::Heavy,
177 _ => WarmNeed::None,
178 }
179}
180
181pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
188 if project_root.is_empty() {
189 return false;
190 }
191 match warm_need_for_tool(tool) {
192 WarmNeed::None => false,
193 WarmNeed::Search => {
194 crate::core::search_index::ensure_background(project_root, true, false);
197 false
198 }
199 WarmNeed::Heavy => {
200 let entry = entry_for(project_root);
201 let first_warm = {
202 let mut s = entry
203 .lock()
204 .unwrap_or_else(std::sync::PoisonError::into_inner);
205 if s.warm_triggered {
206 false
207 } else {
208 s.warm_triggered = true;
209 true
210 }
211 };
212 if first_warm {
213 ensure_all_background(project_root);
214 }
215 first_warm
216 }
217 }
218}
219
220const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
224
225fn nudge_daemon_index(project_root: &str) {
237 if crate::daemon::is_foreground_daemon() {
239 return;
240 }
241 let root = project_root.to_string();
242 let _ = std::thread::Builder::new()
243 .name("leanctx-index-nudge".to_string())
244 .spawn(move || {
245 if !crate::daemon::is_daemon_running() {
246 return;
247 }
248 let Ok(rt) = tokio::runtime::Runtime::new() else {
249 return;
250 };
251 let body = serde_json::json!({ "root": root }).to_string();
252 rt.block_on(async {
253 let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
254 .await;
255 });
256 });
257}
258
259fn try_claim_worker(project_root: &str) -> bool {
263 let state = entry_for(project_root);
264 let mut s = state
265 .lock()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 if s.worker_running {
268 false
269 } else {
270 s.worker_running = true;
271 s.graph_run_done = false;
272 s.bm25_run_done = false;
273 true
274 }
275}
276
277pub fn ensure_all_background(project_root: &str) {
278 if !try_claim_worker(project_root) {
279 return;
280 }
281
282 if !crate::core::index_filter::cli_overlay_active() {
291 nudge_daemon_index(project_root);
292 }
293
294 let state = entry_for(project_root);
295 let root = project_root.to_string();
296 let indexer = move || run_build_worker(&root);
297
298 let spawned = std::thread::Builder::new()
302 .name("leanctx-index".to_string())
303 .stack_size(INDEXER_STACK_BYTES)
304 .spawn(indexer);
305 if spawned.is_err() {
306 let mut s = state
309 .lock()
310 .unwrap_or_else(std::sync::PoisonError::into_inner);
311 s.worker_running = false;
312 }
313}
314
315fn run_build_worker(root: &str) {
320 crate::core::memory_guard::start_guard(std::sync::Arc::new(|level| {
327 tracing::warn!(
328 "[build_worker] memory pressure: {level:?} — background tasks will throttle"
329 );
330 if level >= crate::core::memory_guard::PressureLevel::Hard {
331 crate::core::content_cache::clear();
332 }
333 crate::core::memory_guard::force_purge();
334 true
335 }));
336
337 let graph_state = entry_for(root);
343 let graph_root = root.to_string();
344 let build_graph = move || {
345 {
346 let mut s = graph_state
347 .lock()
348 .unwrap_or_else(std::sync::PoisonError::into_inner);
349 start_component(&mut s.graph);
350 }
351 let guard = crate::core::index_progress::ProgressGuard::new(
353 graph_root.clone(),
354 crate::core::index_progress::IndexComponent::Graph,
355 );
356 guard.report(0, 0);
357 let graph_result = std::panic::catch_unwind(|| {
358 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
359 if let Err(e) = idx.save() {
360 tracing::warn!("[index_orchestrator: graph save failed: {e}]");
361 }
362 crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
363 });
364 drop(guard);
365 {
366 let mut s = graph_state
367 .lock()
368 .unwrap_or_else(std::sync::PoisonError::into_inner);
369 if let Ok(()) = graph_result {
370 finish_ok(&mut s.graph);
371 } else {
372 finish_err(&mut s.graph, "graph index build panicked".to_string());
373 }
374 s.graph_run_done = true;
375 }
376 };
377
378 let bm25_state = entry_for(root);
379 let bm25_root = root.to_string();
380 let build_bm25 = move || {
381 {
382 let mut s = bm25_state
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 start_component(&mut s.bm25);
386 }
387 let _progress = crate::core::index_progress::ProgressGuard::new(
388 bm25_root.clone(),
389 crate::core::index_progress::IndexComponent::Bm25,
390 );
391 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
392 let root_pb = Path::new(&bm25_root);
393 let lock_name = bm25_index_lock_name(root_pb);
394 let _lock = crate::core::startup_guard::try_acquire_lock(
395 &lock_name,
396 std::time::Duration::from_millis(800),
397 std::time::Duration::from_mins(3),
398 );
399 if _lock.is_none() {
400 tracing::info!(
401 "[bm25: another process is building {bm25_root} — loading the shared index]"
402 );
403 let idx = BM25Index::load(root_pb).unwrap_or_default();
404 return (idx.doc_count, None);
405 }
406 let idx = BM25Index::load_or_build(root_pb);
407 let outcome = idx.save(root_pb);
408 (idx.doc_count, Some(outcome))
409 }));
410 {
411 let mut s = bm25_state
412 .lock()
413 .unwrap_or_else(std::sync::PoisonError::into_inner);
414 if let Ok((doc_count, save_res)) = bm {
415 finish_ok(&mut s.bm25);
416 s.bm25.note = Some(match save_res {
417 Some(outcome) => bm25_build_note(doc_count, &outcome),
418 None => format!(
419 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
420 ),
421 });
422 } else {
423 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
424 }
425 s.bm25_run_done = true;
426 }
427 };
428
429 run_memory_bounded_phases(
430 build_graph,
431 build_bm25,
432 || crate::core::search_index::ensure_background(root, true, false),
433 || {
434 crate::core::content_cache::clear();
435 crate::core::memory_guard::force_purge();
436 },
437 );
438
439 crate::core::content_cache::trim_oldest_percent(75);
446 crate::core::memory_guard::force_purge();
447
448 let final_state = entry_for(root);
449 let mut s = final_state
450 .lock()
451 .unwrap_or_else(std::sync::PoisonError::into_inner);
452 s.worker_running = false;
453}
454
455fn run_memory_bounded_phases<G, B, S, R>(graph: G, bm25: B, search: S, mut reclaim: R)
458where
459 G: FnOnce(),
460 B: FnOnce(),
461 S: FnOnce(),
462 R: FnMut(),
463{
464 graph();
465 reclaim();
466 bm25();
467 reclaim();
468 search();
469}
470
471pub fn build_semantic(project_root: &str) {
476 let state = entry_for(project_root);
477 let root = Path::new(project_root);
478
479 {
480 let mut s = state
481 .lock()
482 .unwrap_or_else(std::sync::PoisonError::into_inner);
483 start_component(&mut s.semantic);
484 }
485
486 let _progress = crate::core::index_progress::ProgressGuard::new(
488 project_root.to_string(),
489 crate::core::index_progress::IndexComponent::Semantic,
490 );
491
492 let bm25_idx = try_load_bm25_index(project_root);
493 match bm25_idx.as_ref() {
494 Some(idx) if idx.doc_count > 0 => {
495 let outcome = crate::core::embedding_index::build_or_update(root, idx);
496 let mut s = state
497 .lock()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 match outcome {
500 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
501 finish_ok(&mut s.semantic);
502 }
503 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
504 finish_ok(&mut s.semantic);
505 s.semantic.note = Some(
506 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
507 .to_string(),
508 );
509 }
510 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
511 ref reason,
512 ) => {
513 s.semantic.state = State::Idle;
516 s.semantic.note = Some(format!("embedding model not available: {reason}"));
517 }
518 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
519 finish_err(
520 &mut s.semantic,
521 "embedding build failed (see logs)".to_string(),
522 );
523 }
524 }
525 }
526 _ => {
527 let mut s = state
528 .lock()
529 .unwrap_or_else(std::sync::PoisonError::into_inner);
530 s.semantic.state = State::Idle;
532 s.semantic.note =
533 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
534 }
535 }
536}
537
538const MAX_EXTRA_ROOT_BUILDS: usize = 8;
542
543pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
544 let primary = Path::new(primary_root);
545 let mut queue: Vec<String> = Vec::new();
546 for root in extra_roots {
547 if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
548 break;
549 }
550 let rp = Path::new(root);
551 if !rp.is_dir() {
552 continue;
553 }
554 if rp.starts_with(primary) {
556 continue;
557 }
558 if primary.starts_with(rp) {
560 continue;
561 }
562 queue.push(root.clone());
563 }
564 if queue.is_empty() {
565 return;
566 }
567
568 let spawned = std::thread::Builder::new()
577 .name("leanctx-extra-roots".to_string())
578 .stack_size(INDEXER_STACK_BYTES)
579 .spawn(move || {
580 for root in queue {
581 if crate::core::memory_guard::is_under_pressure()
582 || crate::core::memory_guard::abort_requested()
583 {
584 tracing::warn!(
585 "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
586 );
587 break;
588 }
589 if !try_claim_worker(&root) {
590 continue; }
592 nudge_daemon_index(&root);
593 run_build_worker(&root);
594 }
595 });
596 if let Err(e) = spawned {
597 tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
598 }
599}
600
601fn bm25_build_note(
606 doc_count: usize,
607 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
608) -> String {
609 use crate::core::bm25_index::SaveOutcome;
610 match save {
611 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
612 "indexed {doc_count} chunks, {:.1} MB persisted",
613 *compressed_bytes as f64 / 1_048_576.0
614 ),
615 Ok(SaveOutcome::SkippedTooLarge {
616 compressed_bytes,
617 limit_bytes,
618 }) => format!(
619 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
620 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
621 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
622 *compressed_bytes as f64 / 1_048_576.0,
623 *limit_bytes as f64 / 1_048_576.0
624 ),
625 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
626 }
627}
628
629#[derive(Debug, Clone)]
632pub struct Bm25Summary {
633 pub state: &'static str,
634 pub elapsed_ms: Option<u64>,
636 pub note: Option<String>,
637 pub last_error: Option<String>,
638}
639
640#[derive(Debug, Clone)]
642pub struct SemanticSummary {
643 pub state: &'static str,
644 pub elapsed_ms: Option<u64>,
645 pub note: Option<String>,
646 pub last_error: Option<String>,
647}
648
649fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
653 let elapsed_ms = if matches!(c.state, State::Building) {
654 c.started_ms.map(|start| now_ms().saturating_sub(start))
655 } else {
656 c.duration_ms
657 };
658 let state = match c.state {
659 State::Idle => "idle",
660 State::Building => "building",
661 State::Ready => "ready",
662 State::Failed => "failed",
663 };
664 (state, elapsed_ms)
665}
666
667pub fn semantic_summary(project_root: &str) -> SemanticSummary {
668 let entry = entry_for(project_root);
669 let s = entry
670 .lock()
671 .unwrap_or_else(std::sync::PoisonError::into_inner);
672 let c = &s.semantic;
673 let (state, elapsed_ms) = component_elapsed_and_state(c);
674 SemanticSummary {
675 state,
676 elapsed_ms,
677 note: c.note.clone(),
678 last_error: c.last_error.clone(),
679 }
680}
681
682pub fn bm25_summary(project_root: &str) -> Bm25Summary {
683 let entry = entry_for(project_root);
684 let s = entry
685 .lock()
686 .unwrap_or_else(std::sync::PoisonError::into_inner);
687 let c = &s.bm25;
688 let (state, elapsed_ms) = component_elapsed_and_state(c);
689 Bm25Summary {
690 state,
691 elapsed_ms,
692 note: c.note.clone(),
693 last_error: c.last_error.clone(),
694 }
695}
696
697pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
698 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
701}
702
703pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
704 BM25Index::load(Path::new(project_root))
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
709pub struct SlotProgress {
710 pub building: bool,
711 pub done: u64,
712 pub total: u64,
713}
714
715impl SlotProgress {
716 pub fn is_determinate(&self) -> bool {
717 self.building && self.total > 0
718 }
719}
720
721#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct ProgressView {
724 pub worker_running: bool,
725 pub graph_run_done: bool,
727 pub bm25_run_done: bool,
729 pub graph: SlotProgress,
730 pub bm25: SlotProgress,
731 pub semantic: SlotProgress,
732}
733
734impl ProgressView {
735 pub fn graph_bm25_active(&self) -> bool {
740 self.worker_running && !(self.graph_run_done && self.bm25_run_done)
741 }
742
743 pub fn semantic_active(&self) -> bool {
744 self.semantic.building
745 }
746}
747
748pub fn progress_view(project_root: &str) -> ProgressView {
750 use crate::core::index_progress::{self, IndexComponent};
751 let (worker_running, graph_run_done, bm25_run_done, graph_b, bm25_b, sem_b) = {
752 let state = entry_for(project_root);
753 let s = state
754 .lock()
755 .unwrap_or_else(std::sync::PoisonError::into_inner);
756 (
757 s.worker_running,
758 s.graph_run_done,
759 s.bm25_run_done,
760 matches!(s.graph.state, State::Building),
761 matches!(s.bm25.state, State::Building),
762 matches!(s.semantic.state, State::Building),
763 )
764 };
765 let g = index_progress::get(project_root, IndexComponent::Graph);
766 let b = index_progress::get(project_root, IndexComponent::Bm25);
767 let sem = index_progress::get(project_root, IndexComponent::Semantic);
768 ProgressView {
769 worker_running,
770 graph_run_done,
771 bm25_run_done,
772 graph: SlotProgress {
773 building: graph_b,
774 done: g.done,
775 total: g.total,
776 },
777 bm25: SlotProgress {
778 building: bm25_b,
779 done: b.done,
780 total: b.total,
781 },
782 semantic: SlotProgress {
783 building: sem_b,
784 done: sem.done,
785 total: sem.total,
786 },
787 }
788}
789
790pub fn is_building() -> bool {
792 let map = registry()
793 .lock()
794 .unwrap_or_else(std::sync::PoisonError::into_inner);
795 map.values().any(|entry| {
796 let st = entry
797 .lock()
798 .unwrap_or_else(std::sync::PoisonError::into_inner);
799 matches!(st.bm25.state, State::Building)
800 || matches!(st.graph.state, State::Building)
801 || matches!(st.semantic.state, State::Building)
802 })
803}
804
805#[derive(Debug, Serialize)]
806struct ComponentStatus<'a> {
807 state: &'a str,
808 started_ms: Option<u64>,
809 finished_ms: Option<u64>,
810 duration_ms: Option<u64>,
811 last_error: Option<&'a str>,
812 #[serde(skip_serializing_if = "Option::is_none")]
813 note: Option<&'a str>,
814 #[serde(skip_serializing_if = "Option::is_none")]
816 progress_done: Option<u64>,
817 #[serde(skip_serializing_if = "Option::is_none")]
819 progress_total: Option<u64>,
820}
821
822fn component_status(
823 c: &Component,
824 progress: crate::core::index_progress::ProgressSnapshot,
825) -> ComponentStatus<'_> {
826 let (progress_done, progress_total) = if matches!(c.state, State::Building) {
827 (Some(progress.done), Some(progress.total))
828 } else {
829 (None, None)
830 };
831 ComponentStatus {
832 state: match c.state {
833 State::Idle => "idle",
834 State::Building => "building",
835 State::Ready => "ready",
836 State::Failed => "failed",
837 },
838 started_ms: c.started_ms,
839 finished_ms: c.finished_ms,
840 duration_ms: c.duration_ms,
841 last_error: c.last_error.as_deref(),
842 note: c.note.as_deref(),
843 progress_done,
844 progress_total,
845 }
846}
847
848#[derive(Debug, Serialize)]
849struct StatusResponse<'a> {
850 project_root: &'a str,
851 graph_index: ComponentStatus<'a>,
852 bm25_index: ComponentStatus<'a>,
853 semantic_index: ComponentStatus<'a>,
857 disk: DiskStatusAll,
858 #[serde(skip_serializing_if = "Option::is_none")]
861 index_filters: Option<String>,
862}
863
864#[derive(Debug, Serialize, Default)]
865pub struct DiskStatus {
866 pub exists: bool,
867 pub size_bytes: Option<u64>,
868 pub file_count: Option<u64>,
869 pub modified_at: Option<String>,
870}
871
872#[derive(Debug, Serialize, Default)]
873pub struct DiskStatusAll {
874 pub graph_index: DiskStatus,
875 pub bm25_index: DiskStatus,
876 pub code_graph: DiskStatus,
877 pub semantic_index: DiskStatus,
881}
882
883fn disk_status_for_graph(project_root: &str) -> DiskStatus {
884 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
889 return DiskStatus::default();
890 };
891 let meta_file = dir.join("graph.meta.json");
892 if !meta_file.exists() {
893 return DiskStatus::default();
894 }
895 let meta = std::fs::metadata(&meta_file).ok();
896 let file_count =
897 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
898 DiskStatus {
899 exists: true,
900 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
901 file_count,
902 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
903 }
904}
905
906fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
907 let root = Path::new(project_root);
908 let path = BM25Index::index_file_path(root);
909 if !path.exists() {
910 return DiskStatus::default();
911 }
912 let meta = std::fs::metadata(&path).ok();
913 DiskStatus {
914 exists: true,
915 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
916 file_count: None,
917 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
918 }
919}
920
921fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
922 let dir = crate::core::property_graph::graph_dir(project_root);
923 let db_path = dir.join("graph.db");
924 if !db_path.exists() {
925 return DiskStatus::default();
926 }
927 let meta = std::fs::metadata(&db_path).ok();
928 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
929 .ok()
930 .and_then(|g| {
931 g.connection()
932 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
933 .ok()
934 .map(|c| c as u64)
935 });
936 DiskStatus {
937 exists: true,
938 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
939 file_count: node_count,
940 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
941 }
942}
943
944fn format_time(t: SystemTime) -> String {
945 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
946 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
947 dt.map_or_else(
948 || format!("{secs}"),
949 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
950 )
951}
952
953pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
954 let root = Path::new(project_root);
955 let dir = crate::core::index_namespace::vectors_dir(root);
956 let bin_path = dir.join("embeddings.bin");
957 if !bin_path.exists() {
958 return DiskStatus::default();
959 }
960 let meta = std::fs::metadata(&bin_path).ok();
961 DiskStatus {
962 exists: true,
963 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
964 file_count: None,
965 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
966 }
967}
968
969pub fn disk_status(project_root: &str) -> DiskStatusAll {
970 DiskStatusAll {
971 graph_index: disk_status_for_graph(project_root),
972 bm25_index: disk_status_for_bm25(project_root),
973 code_graph: disk_status_for_code_graph(project_root),
974 semantic_index: disk_status_for_semantic(project_root),
975 }
976}
977
978pub fn status_json(project_root: &str) -> String {
979 let disk = disk_status(project_root);
983 let state = entry_for(project_root);
984 let s = state
985 .lock()
986 .unwrap_or_else(std::sync::PoisonError::into_inner);
987 let graph_p = crate::core::index_progress::get(
988 project_root,
989 crate::core::index_progress::IndexComponent::Graph,
990 );
991 let bm25_p = crate::core::index_progress::get(
992 project_root,
993 crate::core::index_progress::IndexComponent::Bm25,
994 );
995 let semantic_p = crate::core::index_progress::get(
996 project_root,
997 crate::core::index_progress::IndexComponent::Semantic,
998 );
999 let res = StatusResponse {
1000 project_root,
1001 graph_index: component_status(&s.graph, graph_p),
1002 bm25_index: component_status(&s.bm25, bm25_p),
1003 semantic_index: component_status(&s.semantic, semantic_p),
1004 disk,
1005 index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
1006 };
1007 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1015 fn progress_view_graph_bm25_active_logic() {
1016 let idle = SlotProgress::default();
1017 let mut v = ProgressView {
1018 worker_running: true,
1019 graph_run_done: false,
1020 bm25_run_done: false,
1021 graph: idle,
1022 bm25: idle,
1023 semantic: idle,
1024 };
1025 assert!(v.graph_bm25_active(), "claimed, phases not done");
1026 v.graph_run_done = true;
1027 assert!(v.graph_bm25_active(), "graph done, bm25 pending");
1028 v.bm25_run_done = true;
1029 assert!(
1030 !v.graph_bm25_active(),
1031 "both phases done — search pre-warm must not keep wait open"
1032 );
1033 v.worker_running = false;
1034 assert!(!v.graph_bm25_active());
1035 v.graph_run_done = true;
1037 v.bm25_run_done = true;
1038 assert!(!v.graph_bm25_active());
1039 }
1040
1041 #[test]
1042 fn status_json_is_valid_json() {
1043 let s = status_json("/tmp");
1044 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
1045 }
1046
1047 #[test]
1048 fn background_build_phases_are_serialized_with_reclamation() {
1049 let events = std::cell::RefCell::new(Vec::new());
1050 run_memory_bounded_phases(
1051 || events.borrow_mut().push("graph"),
1052 || events.borrow_mut().push("bm25"),
1053 || events.borrow_mut().push("search"),
1054 || events.borrow_mut().push("reclaim"),
1055 );
1056 assert_eq!(
1057 events.into_inner(),
1058 ["graph", "reclaim", "bm25", "reclaim", "search"]
1059 );
1060 }
1061
1062 #[test]
1063 fn warm_need_classifies_tools() {
1064 for light in [
1066 "ctx_read",
1067 "ctx_shell",
1068 "ctx_tree",
1069 "ctx_knowledge",
1070 "unknown_tool",
1071 ] {
1072 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
1073 }
1074 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
1076 for heavy in [
1077 "ctx_graph",
1078 "ctx_callgraph",
1079 "ctx_routes",
1080 "ctx_repomap",
1081 "ctx_impact",
1082 "ctx_artifacts",
1083 "ctx_semantic_search",
1084 "ctx_provider",
1085 "ctx_compose",
1086 "ctx_explore",
1087 "ctx_review",
1088 ] {
1089 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
1090 }
1091 }
1092
1093 #[test]
1094 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
1095 assert!(!ensure_warm_for_tool("", "ctx_graph"));
1096 let tmp = tempfile::tempdir().unwrap();
1097 let root = tmp.path().to_string_lossy().to_string();
1098 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
1099 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
1100 }
1101
1102 #[test]
1103 fn ensure_warm_heavy_is_once_per_root() {
1104 let tmp = tempfile::tempdir().unwrap();
1108 let root = tmp.path().to_string_lossy().to_string();
1109 assert!(
1110 ensure_warm_for_tool(&root, "ctx_callgraph"),
1111 "first heavy warm must signal true"
1112 );
1113 assert!(
1114 !ensure_warm_for_tool(&root, "ctx_callgraph"),
1115 "second heavy warm must be deduped to false"
1116 );
1117 assert!(
1118 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
1119 "any later heavy tool on the same root is also deduped"
1120 );
1121 }
1122
1123 #[test]
1124 fn build_note_persisted_reports_size() {
1125 let note = bm25_build_note(
1126 42,
1127 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
1128 compressed_bytes: 3 * 1024 * 1024,
1129 }),
1130 );
1131 assert!(
1132 note.contains("42 chunks"),
1133 "note should report chunk count: {note}"
1134 );
1135 assert!(
1136 note.contains("persisted"),
1137 "note should report persistence: {note}"
1138 );
1139 }
1140
1141 #[test]
1142 fn build_note_too_large_carries_remedy() {
1143 let note = bm25_build_note(
1144 1000,
1145 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
1146 compressed_bytes: 600 * 1024 * 1024,
1147 limit_bytes: 512 * 1024 * 1024,
1148 }),
1149 );
1150 assert!(
1151 note.contains("NOT persisted"),
1152 "must flag non-persistence: {note}"
1153 );
1154 assert!(
1155 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
1156 "too-large note must carry an actionable remedy: {note}"
1157 );
1158 }
1159
1160 #[test]
1161 fn build_note_persist_error_is_reported() {
1162 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
1163 assert!(note.contains("persisting failed"), "note: {note}");
1164 assert!(
1165 note.contains("disk full"),
1166 "note should include the io error: {note}"
1167 );
1168 }
1169
1170 #[test]
1171 fn bm25_summary_unknown_project_is_idle() {
1172 let tmp = tempfile::tempdir().unwrap();
1173 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
1174 assert_eq!(summary.state, "idle");
1175 assert!(summary.note.is_none());
1176 assert!(summary.last_error.is_none());
1177 }
1178
1179 #[test]
1180 fn extra_roots_skips_subdirs_of_primary() {
1181 let tmp = tempfile::tempdir().unwrap();
1182 let primary = tmp.path().join("primary");
1183 std::fs::create_dir_all(&primary).unwrap();
1184 let sub = primary.join("subdir");
1185 std::fs::create_dir_all(&sub).unwrap();
1186 let external = tmp.path().join("external");
1187 std::fs::create_dir_all(&external).unwrap();
1188
1189 let primary_str = primary.to_string_lossy().to_string();
1190 let extra = vec![
1191 sub.to_string_lossy().to_string(),
1192 external.to_string_lossy().to_string(),
1193 ];
1194
1195 ensure_extra_roots_background(&primary_str, &extra);
1197 }
1198
1199 #[test]
1200 fn extra_roots_caps_at_max() {
1201 let tmp = tempfile::tempdir().unwrap();
1202 let primary = tmp.path().join("primary");
1203 std::fs::create_dir_all(&primary).unwrap();
1204
1205 let mut extra = Vec::new();
1206 for i in 0..20 {
1207 let d = tmp.path().join(format!("ext-{i}"));
1208 std::fs::create_dir_all(&d).unwrap();
1209 extra.push(d.to_string_lossy().to_string());
1210 }
1211
1212 let primary_str = primary.to_string_lossy().to_string();
1213 ensure_extra_roots_background(&primary_str, &extra);
1215 }
1216
1217 #[test]
1218 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1219 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1220 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1221 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1222 assert_ne!(a, b, "lock name must be per-repo");
1223 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1225 let graph = format!(
1228 "graph-idx-{}",
1229 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1230 );
1231 assert_ne!(a, graph, "bm25 and graph locks must be independent");
1232 }
1233}