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 semantic: Component,
58}
59
60impl ProjectBuild {
61 fn new() -> Self {
62 Self {
63 worker_running: false,
64 warm_triggered: false,
65 graph: Component::new(),
66 bm25: Component::new(),
67 semantic: Component::new(),
68 }
69 }
70}
71
72static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
80
81fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
82 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
83}
84
85fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
86 let mut map = registry()
87 .lock()
88 .unwrap_or_else(std::sync::PoisonError::into_inner);
89 map.entry(project_root.to_string())
90 .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
91 .clone()
92}
93
94fn now_ms() -> u64 {
95 SystemTime::now()
96 .duration_since(UNIX_EPOCH)
97 .unwrap_or_default()
98 .as_millis() as u64
99}
100
101fn bm25_index_lock_name(root: &Path) -> String {
106 format!(
107 "bm25-idx-{}",
108 &crate::core::index_namespace::namespace_hash(root)[..8]
109 )
110}
111
112fn start_component(c: &mut Component) {
113 c.state = State::Building;
114 c.started_ms = Some(now_ms());
115 c.finished_ms = None;
116 c.duration_ms = None;
117 c.last_error = None;
118 c.note = None;
119}
120
121fn finish_ok(c: &mut Component) {
122 c.state = State::Ready;
123 let end = now_ms();
124 c.finished_ms = Some(end);
125 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
126}
127
128fn finish_err(c: &mut Component, e: String) {
129 c.state = State::Failed;
130 let end = now_ms();
131 c.finished_ms = Some(end);
132 c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
133 c.last_error = Some(e);
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum WarmNeed {
142 None,
144 Search,
146 Heavy,
148}
149
150#[must_use]
154pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
155 match tool {
156 "ctx_search" => WarmNeed::Search,
157 "ctx_graph"
159 | "ctx_callgraph"
160 | "ctx_routes"
161 | "ctx_repomap"
162 | "ctx_impact"
163 | "ctx_artifacts"
164 | "ctx_semantic_search"
165 | "ctx_provider"
166 | "ctx_compose"
167 | "ctx_explore"
168 | "ctx_review" => WarmNeed::Heavy,
169 _ => WarmNeed::None,
170 }
171}
172
173pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
180 if project_root.is_empty() {
181 return false;
182 }
183 match warm_need_for_tool(tool) {
184 WarmNeed::None => false,
185 WarmNeed::Search => {
186 crate::core::search_index::ensure_background(project_root, true, false);
189 false
190 }
191 WarmNeed::Heavy => {
192 let entry = entry_for(project_root);
193 let first_warm = {
194 let mut s = entry
195 .lock()
196 .unwrap_or_else(std::sync::PoisonError::into_inner);
197 if s.warm_triggered {
198 false
199 } else {
200 s.warm_triggered = true;
201 true
202 }
203 };
204 if first_warm {
205 ensure_all_background(project_root);
206 }
207 first_warm
208 }
209 }
210}
211
212const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
216
217fn nudge_daemon_index(project_root: &str) {
229 if crate::daemon::is_foreground_daemon() {
231 return;
232 }
233 let root = project_root.to_string();
234 let _ = std::thread::Builder::new()
235 .name("leanctx-index-nudge".to_string())
236 .spawn(move || {
237 if !crate::daemon::is_daemon_running() {
238 return;
239 }
240 let Ok(rt) = tokio::runtime::Runtime::new() else {
241 return;
242 };
243 let body = serde_json::json!({ "root": root }).to_string();
244 rt.block_on(async {
245 let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
246 .await;
247 });
248 });
249}
250
251fn try_claim_worker(project_root: &str) -> bool {
255 let state = entry_for(project_root);
256 let mut s = state
257 .lock()
258 .unwrap_or_else(std::sync::PoisonError::into_inner);
259 if s.worker_running {
260 false
261 } else {
262 s.worker_running = true;
263 true
264 }
265}
266
267pub fn ensure_all_background(project_root: &str) {
268 if !try_claim_worker(project_root) {
269 return;
270 }
271
272 if !crate::core::index_filter::cli_overlay_active() {
281 nudge_daemon_index(project_root);
282 }
283
284 let state = entry_for(project_root);
285 let root = project_root.to_string();
286 let indexer = move || run_build_worker(&root);
287
288 let spawned = std::thread::Builder::new()
292 .name("leanctx-index".to_string())
293 .stack_size(INDEXER_STACK_BYTES)
294 .spawn(indexer);
295 if spawned.is_err() {
296 let mut s = state
299 .lock()
300 .unwrap_or_else(std::sync::PoisonError::into_inner);
301 s.worker_running = false;
302 }
303}
304
305fn run_build_worker(root: &str) {
310 crate::core::memory_guard::start_guard(std::sync::Arc::new(|level| {
317 tracing::warn!(
318 "[build_worker] memory pressure: {level:?} — background tasks will throttle"
319 );
320 if level >= crate::core::memory_guard::PressureLevel::Hard {
321 crate::core::content_cache::clear();
322 }
323 crate::core::memory_guard::force_purge();
324 }));
325
326 crate::core::search_index::ensure_background(root, true, false);
329
330 let sequential = crate::core::memory_guard::is_under_pressure();
334 if sequential {
335 tracing::info!(
336 "[index_orchestrator: memory pressure detected — running graph → BM25 sequentially]"
337 );
338 }
339
340 let graph_state = entry_for(root);
341 let graph_root = root.to_string();
342 let build_graph = move || {
343 {
344 let mut s = graph_state
345 .lock()
346 .unwrap_or_else(std::sync::PoisonError::into_inner);
347 start_component(&mut s.graph);
348 }
349 let graph_result = std::panic::catch_unwind(|| {
350 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
351 if let Err(e) = idx.save() {
352 tracing::warn!("[index_orchestrator: graph save failed: {e}]");
353 }
354 crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
355 });
356 if let Ok(()) = graph_result {
357 let mut s = graph_state
358 .lock()
359 .unwrap_or_else(std::sync::PoisonError::into_inner);
360 finish_ok(&mut s.graph);
361 } else {
362 let mut s = graph_state
363 .lock()
364 .unwrap_or_else(std::sync::PoisonError::into_inner);
365 finish_err(&mut s.graph, "graph index build panicked".to_string());
366 }
367 };
368
369 let bm25_state = entry_for(root);
370 let bm25_root = root.to_string();
371 let build_bm25 = move || {
372 {
373 let mut s = bm25_state
374 .lock()
375 .unwrap_or_else(std::sync::PoisonError::into_inner);
376 start_component(&mut s.bm25);
377 }
378 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
379 let root_pb = Path::new(&bm25_root);
380 let lock_name = bm25_index_lock_name(root_pb);
381 let _lock = crate::core::startup_guard::try_acquire_lock(
382 &lock_name,
383 std::time::Duration::from_millis(800),
384 std::time::Duration::from_mins(3),
385 );
386 if _lock.is_none() {
387 tracing::info!(
388 "[bm25: another process is building {bm25_root} — loading the shared index]"
389 );
390 let idx = BM25Index::load(root_pb).unwrap_or_default();
391 return (idx.doc_count, None);
392 }
393 let idx = BM25Index::load_or_build(root_pb);
394 let outcome = idx.save(root_pb);
395 (idx.doc_count, Some(outcome))
396 }));
397 if let Ok((doc_count, save_res)) = bm {
398 let mut s = bm25_state
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner);
401 finish_ok(&mut s.bm25);
402 s.bm25.note = Some(match save_res {
403 Some(outcome) => bm25_build_note(doc_count, &outcome),
404 None => format!(
405 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
406 ),
407 });
408 } else {
409 let mut s = bm25_state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
413 }
414 };
415
416 if sequential {
417 build_graph();
418 crate::core::content_cache::clear();
419 crate::core::memory_guard::force_purge();
420 build_bm25();
421 } else {
422 let graph_handle = std::thread::Builder::new()
423 .name("leanctx-graph".to_string())
424 .stack_size(INDEXER_STACK_BYTES)
425 .spawn(build_graph)
426 .expect("spawning graph index thread");
427 let bm25_handle = std::thread::Builder::new()
428 .name("leanctx-bm25".to_string())
429 .stack_size(INDEXER_STACK_BYTES)
430 .spawn(build_bm25)
431 .expect("spawning BM25 index thread");
432 if let Err(e) = graph_handle.join() {
433 tracing::error!("[index_orchestrator: graph thread panicked: {e:?}]");
434 }
435 if let Err(e) = bm25_handle.join() {
436 tracing::error!("[index_orchestrator: BM25 thread panicked: {e:?}]");
437 }
438 }
439
440 crate::core::content_cache::trim_oldest_percent(75);
447 crate::core::memory_guard::force_purge();
448
449 let final_state = entry_for(root);
450 let mut s = final_state
451 .lock()
452 .unwrap_or_else(std::sync::PoisonError::into_inner);
453 s.worker_running = false;
454}
455
456pub fn build_semantic(project_root: &str) {
461 let state = entry_for(project_root);
462 let root = Path::new(project_root);
463
464 {
465 let mut s = state
466 .lock()
467 .unwrap_or_else(std::sync::PoisonError::into_inner);
468 start_component(&mut s.semantic);
469 }
470
471 let bm25_idx = try_load_bm25_index(project_root);
472 match bm25_idx.as_ref() {
473 Some(idx) if idx.doc_count > 0 => {
474 let outcome = crate::core::embedding_index::build_or_update(root, idx);
475 let mut s = state
476 .lock()
477 .unwrap_or_else(std::sync::PoisonError::into_inner);
478 match outcome {
479 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
480 finish_ok(&mut s.semantic);
481 }
482 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
483 finish_ok(&mut s.semantic);
484 s.semantic.note = Some(
485 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
486 .to_string(),
487 );
488 }
489 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
490 ref reason,
491 ) => {
492 s.semantic.state = State::Idle;
493 s.semantic.note = Some(format!("embedding model not available: {reason}"));
494 }
495 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
496 finish_err(
497 &mut s.semantic,
498 "embedding build failed (see logs)".to_string(),
499 );
500 }
501 }
502 }
503 _ => {
504 let mut s = state
505 .lock()
506 .unwrap_or_else(std::sync::PoisonError::into_inner);
507 s.semantic.state = State::Idle;
508 s.semantic.note =
509 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
510 }
511 }
512}
513
514const MAX_EXTRA_ROOT_BUILDS: usize = 8;
518
519pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
520 let primary = Path::new(primary_root);
521 let mut queue: Vec<String> = Vec::new();
522 for root in extra_roots {
523 if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
524 break;
525 }
526 let rp = Path::new(root);
527 if !rp.is_dir() {
528 continue;
529 }
530 if rp.starts_with(primary) {
532 continue;
533 }
534 if primary.starts_with(rp) {
536 continue;
537 }
538 queue.push(root.clone());
539 }
540 if queue.is_empty() {
541 return;
542 }
543
544 let spawned = std::thread::Builder::new()
553 .name("leanctx-extra-roots".to_string())
554 .stack_size(INDEXER_STACK_BYTES)
555 .spawn(move || {
556 for root in queue {
557 if crate::core::memory_guard::is_under_pressure()
558 || crate::core::memory_guard::abort_requested()
559 {
560 tracing::warn!(
561 "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
562 );
563 break;
564 }
565 if !try_claim_worker(&root) {
566 continue; }
568 nudge_daemon_index(&root);
569 run_build_worker(&root);
570 }
571 });
572 if let Err(e) = spawned {
573 tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
574 }
575}
576
577fn bm25_build_note(
582 doc_count: usize,
583 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
584) -> String {
585 use crate::core::bm25_index::SaveOutcome;
586 match save {
587 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
588 "indexed {doc_count} chunks, {:.1} MB persisted",
589 *compressed_bytes as f64 / 1_048_576.0
590 ),
591 Ok(SaveOutcome::SkippedTooLarge {
592 compressed_bytes,
593 limit_bytes,
594 }) => format!(
595 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
596 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
597 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
598 *compressed_bytes as f64 / 1_048_576.0,
599 *limit_bytes as f64 / 1_048_576.0
600 ),
601 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
602 }
603}
604
605#[derive(Debug, Clone)]
608pub struct Bm25Summary {
609 pub state: &'static str,
610 pub elapsed_ms: Option<u64>,
612 pub note: Option<String>,
613 pub last_error: Option<String>,
614}
615
616#[derive(Debug, Clone)]
618pub struct SemanticSummary {
619 pub state: &'static str,
620 pub elapsed_ms: Option<u64>,
621 pub note: Option<String>,
622 pub last_error: Option<String>,
623}
624
625fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
629 let elapsed_ms = if matches!(c.state, State::Building) {
630 c.started_ms.map(|start| now_ms().saturating_sub(start))
631 } else {
632 c.duration_ms
633 };
634 let state = match c.state {
635 State::Idle => "idle",
636 State::Building => "building",
637 State::Ready => "ready",
638 State::Failed => "failed",
639 };
640 (state, elapsed_ms)
641}
642
643pub fn semantic_summary(project_root: &str) -> SemanticSummary {
644 let entry = entry_for(project_root);
645 let s = entry
646 .lock()
647 .unwrap_or_else(std::sync::PoisonError::into_inner);
648 let c = &s.semantic;
649 let (state, elapsed_ms) = component_elapsed_and_state(c);
650 SemanticSummary {
651 state,
652 elapsed_ms,
653 note: c.note.clone(),
654 last_error: c.last_error.clone(),
655 }
656}
657
658pub fn bm25_summary(project_root: &str) -> Bm25Summary {
659 let entry = entry_for(project_root);
660 let s = entry
661 .lock()
662 .unwrap_or_else(std::sync::PoisonError::into_inner);
663 let c = &s.bm25;
664 let (state, elapsed_ms) = component_elapsed_and_state(c);
665 Bm25Summary {
666 state,
667 elapsed_ms,
668 note: c.note.clone(),
669 last_error: c.last_error.clone(),
670 }
671}
672
673pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
674 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
677}
678
679pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
680 BM25Index::load(Path::new(project_root))
681}
682
683pub fn is_building() -> bool {
685 let map = registry()
686 .lock()
687 .unwrap_or_else(std::sync::PoisonError::into_inner);
688 map.values().any(|entry| {
689 let st = entry
690 .lock()
691 .unwrap_or_else(std::sync::PoisonError::into_inner);
692 matches!(st.bm25.state, State::Building)
693 || matches!(st.graph.state, State::Building)
694 || matches!(st.semantic.state, State::Building)
695 })
696}
697
698#[derive(Debug, Serialize)]
699struct ComponentStatus<'a> {
700 state: &'a str,
701 started_ms: Option<u64>,
702 finished_ms: Option<u64>,
703 duration_ms: Option<u64>,
704 last_error: Option<&'a str>,
705 #[serde(skip_serializing_if = "Option::is_none")]
706 note: Option<&'a str>,
707}
708
709fn component_status(c: &Component) -> ComponentStatus<'_> {
710 ComponentStatus {
711 state: match c.state {
712 State::Idle => "idle",
713 State::Building => "building",
714 State::Ready => "ready",
715 State::Failed => "failed",
716 },
717 started_ms: c.started_ms,
718 finished_ms: c.finished_ms,
719 duration_ms: c.duration_ms,
720 last_error: c.last_error.as_deref(),
721 note: c.note.as_deref(),
722 }
723}
724
725#[derive(Debug, Serialize)]
726struct StatusResponse<'a> {
727 project_root: &'a str,
728 graph_index: ComponentStatus<'a>,
729 bm25_index: ComponentStatus<'a>,
730 semantic_index: ComponentStatus<'a>,
734 disk: DiskStatusAll,
735 #[serde(skip_serializing_if = "Option::is_none")]
738 index_filters: Option<String>,
739}
740
741#[derive(Debug, Serialize, Default)]
742pub struct DiskStatus {
743 pub exists: bool,
744 pub size_bytes: Option<u64>,
745 pub file_count: Option<u64>,
746 pub modified_at: Option<String>,
747}
748
749#[derive(Debug, Serialize, Default)]
750pub struct DiskStatusAll {
751 pub graph_index: DiskStatus,
752 pub bm25_index: DiskStatus,
753 pub code_graph: DiskStatus,
754 pub semantic_index: DiskStatus,
758}
759
760fn disk_status_for_graph(project_root: &str) -> DiskStatus {
761 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
766 return DiskStatus::default();
767 };
768 let meta_file = dir.join("graph.meta.json");
769 if !meta_file.exists() {
770 return DiskStatus::default();
771 }
772 let meta = std::fs::metadata(&meta_file).ok();
773 let file_count =
774 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
775 DiskStatus {
776 exists: true,
777 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
778 file_count,
779 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
780 }
781}
782
783fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
784 let root = Path::new(project_root);
785 let path = BM25Index::index_file_path(root);
786 if !path.exists() {
787 return DiskStatus::default();
788 }
789 let meta = std::fs::metadata(&path).ok();
790 DiskStatus {
791 exists: true,
792 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
793 file_count: None,
794 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
795 }
796}
797
798fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
799 let dir = crate::core::property_graph::graph_dir(project_root);
800 let db_path = dir.join("graph.db");
801 if !db_path.exists() {
802 return DiskStatus::default();
803 }
804 let meta = std::fs::metadata(&db_path).ok();
805 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
806 .ok()
807 .and_then(|g| {
808 g.connection()
809 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
810 .ok()
811 .map(|c| c as u64)
812 });
813 DiskStatus {
814 exists: true,
815 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
816 file_count: node_count,
817 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
818 }
819}
820
821fn format_time(t: SystemTime) -> String {
822 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
823 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
824 dt.map_or_else(
825 || format!("{secs}"),
826 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
827 )
828}
829
830pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
831 let root = Path::new(project_root);
832 let dir = crate::core::index_namespace::vectors_dir(root);
833 let bin_path = dir.join("embeddings.bin");
834 if !bin_path.exists() {
835 return DiskStatus::default();
836 }
837 let meta = std::fs::metadata(&bin_path).ok();
838 DiskStatus {
839 exists: true,
840 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
841 file_count: None,
842 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
843 }
844}
845
846pub fn disk_status(project_root: &str) -> DiskStatusAll {
847 DiskStatusAll {
848 graph_index: disk_status_for_graph(project_root),
849 bm25_index: disk_status_for_bm25(project_root),
850 code_graph: disk_status_for_code_graph(project_root),
851 semantic_index: disk_status_for_semantic(project_root),
852 }
853}
854
855pub fn status_json(project_root: &str) -> String {
856 let disk = disk_status(project_root);
860 let state = entry_for(project_root);
861 let s = state
862 .lock()
863 .unwrap_or_else(std::sync::PoisonError::into_inner);
864 let res = StatusResponse {
865 project_root,
866 graph_index: component_status(&s.graph),
867 bm25_index: component_status(&s.bm25),
868 semantic_index: component_status(&s.semantic),
869 disk,
870 index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
871 };
872 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn status_json_is_valid_json() {
881 let s = status_json("/tmp");
882 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
883 }
884
885 #[test]
886 fn warm_need_classifies_tools() {
887 for light in [
889 "ctx_read",
890 "ctx_shell",
891 "ctx_tree",
892 "ctx_knowledge",
893 "unknown_tool",
894 ] {
895 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
896 }
897 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
899 for heavy in [
900 "ctx_graph",
901 "ctx_callgraph",
902 "ctx_routes",
903 "ctx_repomap",
904 "ctx_impact",
905 "ctx_artifacts",
906 "ctx_semantic_search",
907 "ctx_provider",
908 "ctx_compose",
909 "ctx_explore",
910 "ctx_review",
911 ] {
912 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
913 }
914 }
915
916 #[test]
917 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
918 assert!(!ensure_warm_for_tool("", "ctx_graph"));
919 let tmp = tempfile::tempdir().unwrap();
920 let root = tmp.path().to_string_lossy().to_string();
921 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
922 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
923 }
924
925 #[test]
926 fn ensure_warm_heavy_is_once_per_root() {
927 let tmp = tempfile::tempdir().unwrap();
931 let root = tmp.path().to_string_lossy().to_string();
932 assert!(
933 ensure_warm_for_tool(&root, "ctx_callgraph"),
934 "first heavy warm must signal true"
935 );
936 assert!(
937 !ensure_warm_for_tool(&root, "ctx_callgraph"),
938 "second heavy warm must be deduped to false"
939 );
940 assert!(
941 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
942 "any later heavy tool on the same root is also deduped"
943 );
944 }
945
946 #[test]
947 fn build_note_persisted_reports_size() {
948 let note = bm25_build_note(
949 42,
950 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
951 compressed_bytes: 3 * 1024 * 1024,
952 }),
953 );
954 assert!(
955 note.contains("42 chunks"),
956 "note should report chunk count: {note}"
957 );
958 assert!(
959 note.contains("persisted"),
960 "note should report persistence: {note}"
961 );
962 }
963
964 #[test]
965 fn build_note_too_large_carries_remedy() {
966 let note = bm25_build_note(
967 1000,
968 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
969 compressed_bytes: 600 * 1024 * 1024,
970 limit_bytes: 512 * 1024 * 1024,
971 }),
972 );
973 assert!(
974 note.contains("NOT persisted"),
975 "must flag non-persistence: {note}"
976 );
977 assert!(
978 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
979 "too-large note must carry an actionable remedy: {note}"
980 );
981 }
982
983 #[test]
984 fn build_note_persist_error_is_reported() {
985 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
986 assert!(note.contains("persisting failed"), "note: {note}");
987 assert!(
988 note.contains("disk full"),
989 "note should include the io error: {note}"
990 );
991 }
992
993 #[test]
994 fn bm25_summary_unknown_project_is_idle() {
995 let tmp = tempfile::tempdir().unwrap();
996 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
997 assert_eq!(summary.state, "idle");
998 assert!(summary.note.is_none());
999 assert!(summary.last_error.is_none());
1000 }
1001
1002 #[test]
1003 fn extra_roots_skips_subdirs_of_primary() {
1004 let tmp = tempfile::tempdir().unwrap();
1005 let primary = tmp.path().join("primary");
1006 std::fs::create_dir_all(&primary).unwrap();
1007 let sub = primary.join("subdir");
1008 std::fs::create_dir_all(&sub).unwrap();
1009 let external = tmp.path().join("external");
1010 std::fs::create_dir_all(&external).unwrap();
1011
1012 let primary_str = primary.to_string_lossy().to_string();
1013 let extra = vec![
1014 sub.to_string_lossy().to_string(),
1015 external.to_string_lossy().to_string(),
1016 ];
1017
1018 ensure_extra_roots_background(&primary_str, &extra);
1020 }
1021
1022 #[test]
1023 fn extra_roots_caps_at_max() {
1024 let tmp = tempfile::tempdir().unwrap();
1025 let primary = tmp.path().join("primary");
1026 std::fs::create_dir_all(&primary).unwrap();
1027
1028 let mut extra = Vec::new();
1029 for i in 0..20 {
1030 let d = tmp.path().join(format!("ext-{i}"));
1031 std::fs::create_dir_all(&d).unwrap();
1032 extra.push(d.to_string_lossy().to_string());
1033 }
1034
1035 let primary_str = primary.to_string_lossy().to_string();
1036 ensure_extra_roots_background(&primary_str, &extra);
1038 }
1039
1040 #[test]
1041 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1042 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1043 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1044 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1045 assert_ne!(a, b, "lock name must be per-repo");
1046 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1048 let graph = format!(
1051 "graph-idx-{}",
1052 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1053 );
1054 assert_ne!(a, graph, "bm25 and graph locks must be independent");
1055 }
1056}