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 warm_triggered: bool,
52 graph: Component,
53 bm25: Component,
54}
55
56impl ProjectBuild {
57 fn new() -> Self {
58 Self {
59 worker_running: false,
60 warm_triggered: false,
61 graph: Component::new(),
62 bm25: Component::new(),
63 }
64 }
65}
66
67static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
75
76fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
77 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
78}
79
80fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
81 let mut map = registry()
82 .lock()
83 .unwrap_or_else(std::sync::PoisonError::into_inner);
84 map.entry(project_root.to_string())
85 .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
86 .clone()
87}
88
89fn now_ms() -> u64 {
90 SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .unwrap_or_default()
93 .as_millis() as u64
94}
95
96fn bm25_index_lock_name(root: &Path) -> String {
101 format!(
102 "bm25-idx-{}",
103 &crate::core::index_namespace::namespace_hash(root)[..8]
104 )
105}
106
107fn start_component(c: &mut Component) {
108 c.state = State::Building;
109 c.started_ms = Some(now_ms());
110 c.finished_ms = None;
111 c.duration_ms = None;
112 c.last_error = None;
113 c.note = None;
114}
115
116fn finish_ok(c: &mut Component) {
117 c.state = State::Ready;
118 let end = now_ms();
119 c.finished_ms = Some(end);
120 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
121}
122
123fn finish_err(c: &mut Component, e: String) {
124 c.state = State::Failed;
125 let end = now_ms();
126 c.finished_ms = Some(end);
127 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
128 c.last_error = Some(e);
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum WarmNeed {
137 None,
139 Search,
141 Heavy,
143}
144
145#[must_use]
149pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
150 match tool {
151 "ctx_search" => WarmNeed::Search,
152 "ctx_graph"
154 | "ctx_callgraph"
155 | "ctx_routes"
156 | "ctx_repomap"
157 | "ctx_impact"
158 | "ctx_artifacts"
159 | "ctx_semantic_search"
160 | "ctx_provider"
161 | "ctx_compose"
162 | "ctx_review" => WarmNeed::Heavy,
163 _ => WarmNeed::None,
164 }
165}
166
167pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
174 if project_root.is_empty() {
175 return false;
176 }
177 match warm_need_for_tool(tool) {
178 WarmNeed::None => false,
179 WarmNeed::Search => {
180 crate::core::search_index::ensure_background(project_root, true, false);
183 false
184 }
185 WarmNeed::Heavy => {
186 let entry = entry_for(project_root);
187 let first_warm = {
188 let mut s = entry
189 .lock()
190 .unwrap_or_else(std::sync::PoisonError::into_inner);
191 if s.warm_triggered {
192 false
193 } else {
194 s.warm_triggered = true;
195 true
196 }
197 };
198 if first_warm {
199 ensure_all_background(project_root);
200 }
201 first_warm
202 }
203 }
204}
205
206const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
210
211pub fn ensure_all_background(project_root: &str) {
212 let state = entry_for(project_root);
213 let should_spawn = {
214 let mut s = state
215 .lock()
216 .unwrap_or_else(std::sync::PoisonError::into_inner);
217 if s.worker_running {
218 false
219 } else {
220 s.worker_running = true;
221 true
222 }
223 };
224
225 if !should_spawn {
226 return;
227 }
228
229 let root = project_root.to_string();
230 let indexer = move || {
231 let state = entry_for(&root);
232
233 crate::core::search_index::ensure_background(&root, true, false);
236
237 {
239 let mut s = state
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner);
242 start_component(&mut s.graph);
243 }
244 let graph_result = std::panic::catch_unwind(|| {
245 let (idx, content_cache) = graph_index::scan_with_content_cache(&root);
246 if let Err(e) = idx.save() {
252 tracing::warn!("[index_orchestrator: graph save failed: {e}]");
253 }
254 (idx, content_cache)
255 });
256 let content_cache = if let Ok((_idx, cache)) = graph_result {
257 let mut s = state
258 .lock()
259 .unwrap_or_else(std::sync::PoisonError::into_inner);
260 finish_ok(&mut s.graph);
261 cache
262 } else {
263 let mut s = state
264 .lock()
265 .unwrap_or_else(std::sync::PoisonError::into_inner);
266 finish_err(&mut s.graph, "graph index build panicked".to_string());
267 HashMap::new()
268 };
269
270 {
272 let mut s = state
273 .lock()
274 .unwrap_or_else(std::sync::PoisonError::into_inner);
275 start_component(&mut s.bm25);
276 }
277 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
278 let root_pb = Path::new(&root);
279 let lock_name = bm25_index_lock_name(root_pb);
286 let _lock = crate::core::startup_guard::try_acquire_lock(
287 &lock_name,
288 std::time::Duration::from_millis(800),
289 std::time::Duration::from_mins(3),
290 );
291 if _lock.is_none() {
292 tracing::info!(
293 "[bm25: another process is building {root} — loading the shared index]"
294 );
295 let idx = BM25Index::load(root_pb).unwrap_or_default();
296 return (idx.doc_count, None);
297 }
298 let idx = if content_cache.is_empty() {
299 BM25Index::load_or_build(root_pb)
300 } else {
301 BM25Index::build_with_content_hint(root_pb, &content_cache)
302 };
303 let outcome = idx.save(root_pb);
304 (idx.doc_count, Some(outcome))
305 }));
306 if let Ok((doc_count, save_res)) = bm {
307 let mut s = state
308 .lock()
309 .unwrap_or_else(std::sync::PoisonError::into_inner);
310 finish_ok(&mut s.bm25);
311 s.bm25.note = Some(match save_res {
312 Some(outcome) => bm25_build_note(doc_count, &outcome),
313 None => format!(
314 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
315 ),
316 });
317 } else {
318 let mut s = state
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner);
321 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
322 }
323
324 let mut s = state
325 .lock()
326 .unwrap_or_else(std::sync::PoisonError::into_inner);
327 s.worker_running = false;
328 };
329
330 let spawned = std::thread::Builder::new()
334 .name("leanctx-index".to_string())
335 .stack_size(INDEXER_STACK_BYTES)
336 .spawn(indexer);
337 if spawned.is_err() {
338 let mut s = state
341 .lock()
342 .unwrap_or_else(std::sync::PoisonError::into_inner);
343 s.worker_running = false;
344 }
345}
346
347const MAX_EXTRA_ROOT_BUILDS: usize = 8;
351
352pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
353 let primary = Path::new(primary_root);
354 let mut built = 0;
355 for root in extra_roots {
356 if built >= MAX_EXTRA_ROOT_BUILDS {
357 break;
358 }
359 let rp = Path::new(root);
360 if !rp.is_dir() {
361 continue;
362 }
363 if rp.starts_with(primary) {
365 continue;
366 }
367 if primary.starts_with(rp) {
369 continue;
370 }
371 ensure_all_background(root);
372 built += 1;
373 }
374}
375
376fn bm25_build_note(
381 doc_count: usize,
382 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
383) -> String {
384 use crate::core::bm25_index::SaveOutcome;
385 match save {
386 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
387 "indexed {doc_count} chunks, {:.1} MB persisted",
388 *compressed_bytes as f64 / 1_048_576.0
389 ),
390 Ok(SaveOutcome::SkippedTooLarge {
391 compressed_bytes,
392 limit_bytes,
393 }) => format!(
394 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
395 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
396 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
397 *compressed_bytes as f64 / 1_048_576.0,
398 *limit_bytes as f64 / 1_048_576.0
399 ),
400 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
401 }
402}
403
404#[derive(Debug, Clone)]
407pub struct Bm25Summary {
408 pub state: &'static str,
409 pub elapsed_ms: Option<u64>,
411 pub note: Option<String>,
412 pub last_error: Option<String>,
413}
414
415pub fn bm25_summary(project_root: &str) -> Bm25Summary {
416 let entry = entry_for(project_root);
417 let s = entry
418 .lock()
419 .unwrap_or_else(std::sync::PoisonError::into_inner);
420 let c = &s.bm25;
421 let elapsed_ms = if matches!(c.state, State::Building) {
422 c.started_ms.map(|start| now_ms().saturating_sub(start))
423 } else {
424 c.duration_ms
425 };
426 Bm25Summary {
427 state: match c.state {
428 State::Idle => "idle",
429 State::Building => "building",
430 State::Ready => "ready",
431 State::Failed => "failed",
432 },
433 elapsed_ms,
434 note: c.note.clone(),
435 last_error: c.last_error.clone(),
436 }
437}
438
439pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
440 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
443}
444
445pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
446 BM25Index::load(Path::new(project_root))
447}
448
449pub fn is_building() -> bool {
451 let map = registry()
452 .lock()
453 .unwrap_or_else(std::sync::PoisonError::into_inner);
454 map.values().any(|entry| {
455 let s = entry
456 .lock()
457 .unwrap_or_else(std::sync::PoisonError::into_inner);
458 matches!(s.bm25.state, State::Building) || matches!(s.graph.state, State::Building)
459 })
460}
461
462#[derive(Debug, Serialize)]
463struct ComponentStatus<'a> {
464 state: &'a str,
465 started_ms: Option<u64>,
466 finished_ms: Option<u64>,
467 duration_ms: Option<u64>,
468 last_error: Option<&'a str>,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 note: Option<&'a str>,
471}
472
473fn component_status(c: &Component) -> ComponentStatus<'_> {
474 ComponentStatus {
475 state: match c.state {
476 State::Idle => "idle",
477 State::Building => "building",
478 State::Ready => "ready",
479 State::Failed => "failed",
480 },
481 started_ms: c.started_ms,
482 finished_ms: c.finished_ms,
483 duration_ms: c.duration_ms,
484 last_error: c.last_error.as_deref(),
485 note: c.note.as_deref(),
486 }
487}
488
489#[derive(Debug, Serialize)]
490struct StatusResponse<'a> {
491 project_root: &'a str,
492 graph_index: ComponentStatus<'a>,
493 bm25_index: ComponentStatus<'a>,
494 disk: DiskStatusAll,
495}
496
497#[derive(Debug, Serialize, Default)]
498pub struct DiskStatus {
499 pub exists: bool,
500 pub size_bytes: Option<u64>,
501 pub file_count: Option<u64>,
502 pub modified_at: Option<String>,
503}
504
505#[derive(Debug, Serialize, Default)]
506pub struct DiskStatusAll {
507 pub graph_index: DiskStatus,
508 pub bm25_index: DiskStatus,
509 pub code_graph: DiskStatus,
510}
511
512fn disk_status_for_graph(project_root: &str) -> DiskStatus {
513 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
518 return DiskStatus::default();
519 };
520 let meta_file = dir.join("graph.meta.json");
521 if !meta_file.exists() {
522 return DiskStatus::default();
523 }
524 let meta = std::fs::metadata(&meta_file).ok();
525 let file_count =
526 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
527 DiskStatus {
528 exists: true,
529 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
530 file_count,
531 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
532 }
533}
534
535fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
536 let root = Path::new(project_root);
537 let path = BM25Index::index_file_path(root);
538 if !path.exists() {
539 return DiskStatus::default();
540 }
541 let meta = std::fs::metadata(&path).ok();
542 DiskStatus {
543 exists: true,
544 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
545 file_count: None,
546 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
547 }
548}
549
550fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
551 let dir = crate::core::property_graph::graph_dir(project_root);
552 let db_path = dir.join("graph.db");
553 if !db_path.exists() {
554 return DiskStatus::default();
555 }
556 let meta = std::fs::metadata(&db_path).ok();
557 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
558 .ok()
559 .and_then(|g| {
560 g.connection()
561 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
562 .ok()
563 .map(|c| c as u64)
564 });
565 DiskStatus {
566 exists: true,
567 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
568 file_count: node_count,
569 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
570 }
571}
572
573fn format_time(t: SystemTime) -> String {
574 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
575 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
576 dt.map_or_else(
577 || format!("{secs}"),
578 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
579 )
580}
581
582pub fn disk_status(project_root: &str) -> DiskStatusAll {
583 DiskStatusAll {
584 graph_index: disk_status_for_graph(project_root),
585 bm25_index: disk_status_for_bm25(project_root),
586 code_graph: disk_status_for_code_graph(project_root),
587 }
588}
589
590pub fn status_json(project_root: &str) -> String {
591 let state = entry_for(project_root);
592 let s = state
593 .lock()
594 .unwrap_or_else(std::sync::PoisonError::into_inner);
595 let res = StatusResponse {
596 project_root,
597 graph_index: component_status(&s.graph),
598 bm25_index: component_status(&s.bm25),
599 disk: disk_status(project_root),
600 };
601 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn status_json_is_valid_json() {
610 let s = status_json("/tmp");
611 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
612 }
613
614 #[test]
615 fn warm_need_classifies_tools() {
616 for light in [
618 "ctx_read",
619 "ctx_shell",
620 "ctx_tree",
621 "ctx_knowledge",
622 "unknown_tool",
623 ] {
624 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
625 }
626 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
628 for heavy in [
630 "ctx_graph",
631 "ctx_callgraph",
632 "ctx_routes",
633 "ctx_repomap",
634 "ctx_impact",
635 "ctx_artifacts",
636 "ctx_semantic_search",
637 "ctx_provider",
638 "ctx_compose",
639 "ctx_review",
640 ] {
641 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
642 }
643 }
644
645 #[test]
646 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
647 assert!(!ensure_warm_for_tool("", "ctx_graph"));
650 let tmp = tempfile::tempdir().unwrap();
651 let root = tmp.path().to_string_lossy().to_string();
652 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
653 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
654 }
655
656 #[test]
657 fn ensure_warm_heavy_is_once_per_root() {
658 let tmp = tempfile::tempdir().unwrap();
662 let root = tmp.path().to_string_lossy().to_string();
663 assert!(
664 ensure_warm_for_tool(&root, "ctx_callgraph"),
665 "first heavy warm must signal true"
666 );
667 assert!(
668 !ensure_warm_for_tool(&root, "ctx_callgraph"),
669 "second heavy warm must be deduped to false"
670 );
671 assert!(
672 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
673 "any later heavy tool on the same root is also deduped"
674 );
675 }
676
677 #[test]
678 fn build_note_persisted_reports_size() {
679 let note = bm25_build_note(
680 42,
681 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
682 compressed_bytes: 3 * 1024 * 1024,
683 }),
684 );
685 assert!(
686 note.contains("42 chunks"),
687 "note should report chunk count: {note}"
688 );
689 assert!(
690 note.contains("persisted"),
691 "note should report persistence: {note}"
692 );
693 }
694
695 #[test]
696 fn build_note_too_large_carries_remedy() {
697 let note = bm25_build_note(
698 1000,
699 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
700 compressed_bytes: 600 * 1024 * 1024,
701 limit_bytes: 512 * 1024 * 1024,
702 }),
703 );
704 assert!(
705 note.contains("NOT persisted"),
706 "must flag non-persistence: {note}"
707 );
708 assert!(
709 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
710 "too-large note must carry an actionable remedy: {note}"
711 );
712 }
713
714 #[test]
715 fn build_note_persist_error_is_reported() {
716 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
717 assert!(note.contains("persisting failed"), "note: {note}");
718 assert!(
719 note.contains("disk full"),
720 "note should include the io error: {note}"
721 );
722 }
723
724 #[test]
725 fn bm25_summary_unknown_project_is_idle() {
726 let tmp = tempfile::tempdir().unwrap();
727 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
728 assert_eq!(summary.state, "idle");
729 assert!(summary.note.is_none());
730 assert!(summary.last_error.is_none());
731 }
732
733 #[test]
734 fn extra_roots_skips_subdirs_of_primary() {
735 let tmp = tempfile::tempdir().unwrap();
736 let primary = tmp.path().join("primary");
737 std::fs::create_dir_all(&primary).unwrap();
738 let sub = primary.join("subdir");
739 std::fs::create_dir_all(&sub).unwrap();
740 let external = tmp.path().join("external");
741 std::fs::create_dir_all(&external).unwrap();
742
743 let primary_str = primary.to_string_lossy().to_string();
744 let extra = vec![
745 sub.to_string_lossy().to_string(),
746 external.to_string_lossy().to_string(),
747 ];
748
749 ensure_extra_roots_background(&primary_str, &extra);
751 }
752
753 #[test]
754 fn extra_roots_caps_at_max() {
755 let tmp = tempfile::tempdir().unwrap();
756 let primary = tmp.path().join("primary");
757 std::fs::create_dir_all(&primary).unwrap();
758
759 let mut extra = Vec::new();
760 for i in 0..20 {
761 let d = tmp.path().join(format!("ext-{i}"));
762 std::fs::create_dir_all(&d).unwrap();
763 extra.push(d.to_string_lossy().to_string());
764 }
765
766 let primary_str = primary.to_string_lossy().to_string();
767 ensure_extra_roots_background(&primary_str, &extra);
769 }
770
771 #[test]
772 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
773 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
774 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
775 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
776 assert_ne!(a, b, "lock name must be per-repo");
777 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
779 let graph = format!(
782 "graph-idx-{}",
783 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
784 );
785 assert_ne!(a, graph, "bm25 and graph locks must be independent");
786 }
787}