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 start_component(c: &mut Component) {
97 c.state = State::Building;
98 c.started_ms = Some(now_ms());
99 c.finished_ms = None;
100 c.duration_ms = None;
101 c.last_error = None;
102 c.note = None;
103}
104
105fn finish_ok(c: &mut Component) {
106 c.state = State::Ready;
107 let end = now_ms();
108 c.finished_ms = Some(end);
109 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
110}
111
112fn finish_err(c: &mut Component, e: String) {
113 c.state = State::Failed;
114 let end = now_ms();
115 c.finished_ms = Some(end);
116 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
117 c.last_error = Some(e);
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum WarmNeed {
126 None,
128 Search,
130 Heavy,
132}
133
134#[must_use]
138pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
139 match tool {
140 "ctx_search" => WarmNeed::Search,
141 "ctx_graph"
143 | "ctx_callgraph"
144 | "ctx_routes"
145 | "ctx_repomap"
146 | "ctx_impact"
147 | "ctx_artifacts"
148 | "ctx_semantic_search"
149 | "ctx_provider"
150 | "ctx_compose"
151 | "ctx_review" => WarmNeed::Heavy,
152 _ => WarmNeed::None,
153 }
154}
155
156pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
163 if project_root.is_empty() {
164 return false;
165 }
166 match warm_need_for_tool(tool) {
167 WarmNeed::None => false,
168 WarmNeed::Search => {
169 crate::core::search_index::ensure_background(project_root, true, false);
172 false
173 }
174 WarmNeed::Heavy => {
175 let entry = entry_for(project_root);
176 let first_warm = {
177 let mut s = entry
178 .lock()
179 .unwrap_or_else(std::sync::PoisonError::into_inner);
180 if s.warm_triggered {
181 false
182 } else {
183 s.warm_triggered = true;
184 true
185 }
186 };
187 if first_warm {
188 ensure_all_background(project_root);
189 }
190 first_warm
191 }
192 }
193}
194
195const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
199
200pub fn ensure_all_background(project_root: &str) {
201 let state = entry_for(project_root);
202 let should_spawn = {
203 let mut s = state
204 .lock()
205 .unwrap_or_else(std::sync::PoisonError::into_inner);
206 if s.worker_running {
207 false
208 } else {
209 s.worker_running = true;
210 true
211 }
212 };
213
214 if !should_spawn {
215 return;
216 }
217
218 let root = project_root.to_string();
219 let indexer = move || {
220 let state = entry_for(&root);
221
222 crate::core::search_index::ensure_background(&root, true, false);
225
226 {
228 let mut s = state
229 .lock()
230 .unwrap_or_else(std::sync::PoisonError::into_inner);
231 start_component(&mut s.graph);
232 }
233 let graph_result = std::panic::catch_unwind(|| {
234 let (idx, content_cache) = graph_index::scan_with_content_cache(&root);
235 let _ = idx.save();
239 (idx, content_cache)
240 });
241 let content_cache = if let Ok((_idx, cache)) = graph_result {
242 let mut s = state
243 .lock()
244 .unwrap_or_else(std::sync::PoisonError::into_inner);
245 finish_ok(&mut s.graph);
246 cache
247 } else {
248 let mut s = state
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner);
251 finish_err(&mut s.graph, "graph index build panicked".to_string());
252 HashMap::new()
253 };
254
255 {
257 let mut s = state
258 .lock()
259 .unwrap_or_else(std::sync::PoisonError::into_inner);
260 start_component(&mut s.bm25);
261 }
262 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
263 let root_pb = Path::new(&root);
264 let idx = if content_cache.is_empty() {
265 BM25Index::load_or_build(root_pb)
266 } else {
267 BM25Index::build_with_content_hint(root_pb, &content_cache)
268 };
269 let outcome = idx.save(root_pb);
270 (idx.doc_count, outcome)
271 }));
272 if let Ok((doc_count, save_res)) = bm {
273 let mut s = state
274 .lock()
275 .unwrap_or_else(std::sync::PoisonError::into_inner);
276 finish_ok(&mut s.bm25);
277 s.bm25.note = Some(bm25_build_note(doc_count, &save_res));
278 } else {
279 let mut s = state
280 .lock()
281 .unwrap_or_else(std::sync::PoisonError::into_inner);
282 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
283 }
284
285 let mut s = state
286 .lock()
287 .unwrap_or_else(std::sync::PoisonError::into_inner);
288 s.worker_running = false;
289 };
290
291 let spawned = std::thread::Builder::new()
295 .name("leanctx-index".to_string())
296 .stack_size(INDEXER_STACK_BYTES)
297 .spawn(indexer);
298 if spawned.is_err() {
299 let mut s = state
302 .lock()
303 .unwrap_or_else(std::sync::PoisonError::into_inner);
304 s.worker_running = false;
305 }
306}
307
308const MAX_EXTRA_ROOT_BUILDS: usize = 8;
312
313pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
314 let primary = Path::new(primary_root);
315 let mut built = 0;
316 for root in extra_roots {
317 if built >= MAX_EXTRA_ROOT_BUILDS {
318 break;
319 }
320 let rp = Path::new(root);
321 if !rp.is_dir() {
322 continue;
323 }
324 if rp.starts_with(primary) {
326 continue;
327 }
328 if primary.starts_with(rp) {
330 continue;
331 }
332 ensure_all_background(root);
333 built += 1;
334 }
335}
336
337fn bm25_build_note(
342 doc_count: usize,
343 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
344) -> String {
345 use crate::core::bm25_index::SaveOutcome;
346 match save {
347 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
348 "indexed {doc_count} chunks, {:.1} MB persisted",
349 *compressed_bytes as f64 / 1_048_576.0
350 ),
351 Ok(SaveOutcome::SkippedTooLarge {
352 compressed_bytes,
353 limit_bytes,
354 }) => format!(
355 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
356 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
357 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
358 *compressed_bytes as f64 / 1_048_576.0,
359 *limit_bytes as f64 / 1_048_576.0
360 ),
361 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
362 }
363}
364
365#[derive(Debug, Clone)]
368pub struct Bm25Summary {
369 pub state: &'static str,
370 pub elapsed_ms: Option<u64>,
372 pub note: Option<String>,
373 pub last_error: Option<String>,
374}
375
376pub fn bm25_summary(project_root: &str) -> Bm25Summary {
377 let entry = entry_for(project_root);
378 let s = entry
379 .lock()
380 .unwrap_or_else(std::sync::PoisonError::into_inner);
381 let c = &s.bm25;
382 let elapsed_ms = if matches!(c.state, State::Building) {
383 c.started_ms.map(|start| now_ms().saturating_sub(start))
384 } else {
385 c.duration_ms
386 };
387 Bm25Summary {
388 state: match c.state {
389 State::Idle => "idle",
390 State::Building => "building",
391 State::Ready => "ready",
392 State::Failed => "failed",
393 },
394 elapsed_ms,
395 note: c.note.clone(),
396 last_error: c.last_error.clone(),
397 }
398}
399
400pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
401 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
404}
405
406pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
407 BM25Index::load(Path::new(project_root))
408}
409
410pub fn is_building() -> bool {
412 let map = registry()
413 .lock()
414 .unwrap_or_else(std::sync::PoisonError::into_inner);
415 map.values().any(|entry| {
416 let s = entry
417 .lock()
418 .unwrap_or_else(std::sync::PoisonError::into_inner);
419 matches!(s.bm25.state, State::Building) || matches!(s.graph.state, State::Building)
420 })
421}
422
423#[derive(Debug, Serialize)]
424struct ComponentStatus<'a> {
425 state: &'a str,
426 started_ms: Option<u64>,
427 finished_ms: Option<u64>,
428 duration_ms: Option<u64>,
429 last_error: Option<&'a str>,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 note: Option<&'a str>,
432}
433
434fn component_status(c: &Component) -> ComponentStatus<'_> {
435 ComponentStatus {
436 state: match c.state {
437 State::Idle => "idle",
438 State::Building => "building",
439 State::Ready => "ready",
440 State::Failed => "failed",
441 },
442 started_ms: c.started_ms,
443 finished_ms: c.finished_ms,
444 duration_ms: c.duration_ms,
445 last_error: c.last_error.as_deref(),
446 note: c.note.as_deref(),
447 }
448}
449
450#[derive(Debug, Serialize)]
451struct StatusResponse<'a> {
452 project_root: &'a str,
453 graph_index: ComponentStatus<'a>,
454 bm25_index: ComponentStatus<'a>,
455 disk: DiskStatusAll,
456}
457
458#[derive(Debug, Serialize, Default)]
459pub struct DiskStatus {
460 pub exists: bool,
461 pub size_bytes: Option<u64>,
462 pub file_count: Option<u64>,
463 pub modified_at: Option<String>,
464}
465
466#[derive(Debug, Serialize, Default)]
467pub struct DiskStatusAll {
468 pub graph_index: DiskStatus,
469 pub bm25_index: DiskStatus,
470 pub code_graph: DiskStatus,
471}
472
473fn disk_status_for_graph(project_root: &str) -> DiskStatus {
474 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
475 return DiskStatus::default();
476 };
477 let zst = dir.join("index.json.zst");
478 let json = dir.join("index.json");
479 let path = if zst.exists() {
480 zst
481 } else if json.exists() {
482 json
483 } else {
484 return DiskStatus::default();
485 };
486 let meta = std::fs::metadata(&path).ok();
487 let file_count =
488 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
489 DiskStatus {
490 exists: true,
491 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
492 file_count,
493 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
494 }
495}
496
497fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
498 let root = Path::new(project_root);
499 let path = BM25Index::index_file_path(root);
500 if !path.exists() {
501 return DiskStatus::default();
502 }
503 let meta = std::fs::metadata(&path).ok();
504 DiskStatus {
505 exists: true,
506 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
507 file_count: None,
508 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
509 }
510}
511
512fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
513 let dir = crate::core::property_graph::graph_dir(project_root);
514 let db_path = dir.join("graph.db");
515 if !db_path.exists() {
516 return DiskStatus::default();
517 }
518 let meta = std::fs::metadata(&db_path).ok();
519 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
520 .ok()
521 .and_then(|g| {
522 g.connection()
523 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
524 .ok()
525 .map(|c| c as u64)
526 });
527 DiskStatus {
528 exists: true,
529 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
530 file_count: node_count,
531 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
532 }
533}
534
535fn format_time(t: SystemTime) -> String {
536 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
537 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
538 dt.map_or_else(
539 || format!("{secs}"),
540 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
541 )
542}
543
544pub fn disk_status(project_root: &str) -> DiskStatusAll {
545 DiskStatusAll {
546 graph_index: disk_status_for_graph(project_root),
547 bm25_index: disk_status_for_bm25(project_root),
548 code_graph: disk_status_for_code_graph(project_root),
549 }
550}
551
552pub fn status_json(project_root: &str) -> String {
553 let state = entry_for(project_root);
554 let s = state
555 .lock()
556 .unwrap_or_else(std::sync::PoisonError::into_inner);
557 let res = StatusResponse {
558 project_root,
559 graph_index: component_status(&s.graph),
560 bm25_index: component_status(&s.bm25),
561 disk: disk_status(project_root),
562 };
563 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn status_json_is_valid_json() {
572 let s = status_json("/tmp");
573 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
574 }
575
576 #[test]
577 fn warm_need_classifies_tools() {
578 for light in [
580 "ctx_read",
581 "ctx_shell",
582 "ctx_tree",
583 "ctx_knowledge",
584 "unknown_tool",
585 ] {
586 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
587 }
588 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
590 for heavy in [
592 "ctx_graph",
593 "ctx_callgraph",
594 "ctx_routes",
595 "ctx_repomap",
596 "ctx_impact",
597 "ctx_artifacts",
598 "ctx_semantic_search",
599 "ctx_provider",
600 "ctx_compose",
601 "ctx_review",
602 ] {
603 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
604 }
605 }
606
607 #[test]
608 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
609 assert!(!ensure_warm_for_tool("", "ctx_graph"));
612 let tmp = tempfile::tempdir().unwrap();
613 let root = tmp.path().to_string_lossy().to_string();
614 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
615 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
616 }
617
618 #[test]
619 fn ensure_warm_heavy_is_once_per_root() {
620 let tmp = tempfile::tempdir().unwrap();
624 let root = tmp.path().to_string_lossy().to_string();
625 assert!(
626 ensure_warm_for_tool(&root, "ctx_callgraph"),
627 "first heavy warm must signal true"
628 );
629 assert!(
630 !ensure_warm_for_tool(&root, "ctx_callgraph"),
631 "second heavy warm must be deduped to false"
632 );
633 assert!(
634 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
635 "any later heavy tool on the same root is also deduped"
636 );
637 }
638
639 #[test]
640 fn build_note_persisted_reports_size() {
641 let note = bm25_build_note(
642 42,
643 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
644 compressed_bytes: 3 * 1024 * 1024,
645 }),
646 );
647 assert!(
648 note.contains("42 chunks"),
649 "note should report chunk count: {note}"
650 );
651 assert!(
652 note.contains("persisted"),
653 "note should report persistence: {note}"
654 );
655 }
656
657 #[test]
658 fn build_note_too_large_carries_remedy() {
659 let note = bm25_build_note(
660 1000,
661 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
662 compressed_bytes: 600 * 1024 * 1024,
663 limit_bytes: 512 * 1024 * 1024,
664 }),
665 );
666 assert!(
667 note.contains("NOT persisted"),
668 "must flag non-persistence: {note}"
669 );
670 assert!(
671 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
672 "too-large note must carry an actionable remedy: {note}"
673 );
674 }
675
676 #[test]
677 fn build_note_persist_error_is_reported() {
678 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
679 assert!(note.contains("persisting failed"), "note: {note}");
680 assert!(
681 note.contains("disk full"),
682 "note should include the io error: {note}"
683 );
684 }
685
686 #[test]
687 fn bm25_summary_unknown_project_is_idle() {
688 let tmp = tempfile::tempdir().unwrap();
689 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
690 assert_eq!(summary.state, "idle");
691 assert!(summary.note.is_none());
692 assert!(summary.last_error.is_none());
693 }
694
695 #[test]
696 fn extra_roots_skips_subdirs_of_primary() {
697 let tmp = tempfile::tempdir().unwrap();
698 let primary = tmp.path().join("primary");
699 std::fs::create_dir_all(&primary).unwrap();
700 let sub = primary.join("subdir");
701 std::fs::create_dir_all(&sub).unwrap();
702 let external = tmp.path().join("external");
703 std::fs::create_dir_all(&external).unwrap();
704
705 let primary_str = primary.to_string_lossy().to_string();
706 let extra = vec![
707 sub.to_string_lossy().to_string(),
708 external.to_string_lossy().to_string(),
709 ];
710
711 ensure_extra_roots_background(&primary_str, &extra);
713 }
714
715 #[test]
716 fn extra_roots_caps_at_max() {
717 let tmp = tempfile::tempdir().unwrap();
718 let primary = tmp.path().join("primary");
719 std::fs::create_dir_all(&primary).unwrap();
720
721 let mut extra = Vec::new();
722 for i in 0..20 {
723 let d = tmp.path().join(format!("ext-{i}"));
724 std::fs::create_dir_all(&d).unwrap();
725 extra.push(d.to_string_lossy().to_string());
726 }
727
728 let primary_str = primary.to_string_lossy().to_string();
729 ensure_extra_roots_background(&primary_str, &extra);
731 }
732}