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