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 nudge_daemon_index(project_root);
277
278 let state = entry_for(project_root);
279 let root = project_root.to_string();
280 let indexer = move || run_build_worker(&root);
281
282 let spawned = std::thread::Builder::new()
286 .name("leanctx-index".to_string())
287 .stack_size(INDEXER_STACK_BYTES)
288 .spawn(indexer);
289 if spawned.is_err() {
290 let mut s = state
293 .lock()
294 .unwrap_or_else(std::sync::PoisonError::into_inner);
295 s.worker_running = false;
296 }
297}
298
299fn run_build_worker(root: &str) {
304 crate::core::search_index::ensure_background(root, true, false);
307
308 let graph_state = entry_for(root);
310 let graph_root = root.to_string();
311 let graph_handle = std::thread::Builder::new()
312 .name("leanctx-graph".to_string())
313 .stack_size(INDEXER_STACK_BYTES)
314 .spawn(move || {
315 {
316 let mut s = graph_state
317 .lock()
318 .unwrap_or_else(std::sync::PoisonError::into_inner);
319 start_component(&mut s.graph);
320 }
321 let graph_result = std::panic::catch_unwind(|| {
322 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
323 if let Err(e) = idx.save() {
327 tracing::warn!("[index_orchestrator: graph save failed: {e}]");
328 }
329 crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
333 });
334 if let Ok(()) = graph_result {
335 let mut s = graph_state
336 .lock()
337 .unwrap_or_else(std::sync::PoisonError::into_inner);
338 finish_ok(&mut s.graph);
339 } else {
340 let mut s = graph_state
341 .lock()
342 .unwrap_or_else(std::sync::PoisonError::into_inner);
343 finish_err(&mut s.graph, "graph index build panicked".to_string());
344 }
345 })
346 .expect("spawning graph index thread");
347
348 let bm25_state = entry_for(root);
349 let bm25_root = root.to_string();
350 let bm25_handle = std::thread::Builder::new()
351 .name("leanctx-bm25".to_string())
352 .stack_size(INDEXER_STACK_BYTES)
353 .spawn(move || {
354 {
355 let mut s = bm25_state
356 .lock()
357 .unwrap_or_else(std::sync::PoisonError::into_inner);
358 start_component(&mut s.bm25);
359 }
360 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
361 let root_pb = Path::new(&bm25_root);
362 let lock_name = bm25_index_lock_name(root_pb);
365 let _lock = crate::core::startup_guard::try_acquire_lock(
366 &lock_name,
367 std::time::Duration::from_millis(800),
368 std::time::Duration::from_mins(3),
369 );
370 if _lock.is_none() {
371 tracing::info!(
372 "[bm25: another process is building {bm25_root} — loading the shared index]"
373 );
374 let idx = BM25Index::load(root_pb).unwrap_or_default();
375 return (idx.doc_count, None);
376 }
377 let idx = BM25Index::load_or_build(root_pb);
378 let outcome = idx.save(root_pb);
379 (idx.doc_count, Some(outcome))
380 }));
381 if let Ok((doc_count, save_res)) = bm {
382 let mut s = bm25_state
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 finish_ok(&mut s.bm25);
386 s.bm25.note = Some(match save_res {
387 Some(outcome) => bm25_build_note(doc_count, &outcome),
388 None => format!(
389 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
390 ),
391 });
392 } else {
393 let mut s = bm25_state
394 .lock()
395 .unwrap_or_else(std::sync::PoisonError::into_inner);
396 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
397 }
398 })
399 .expect("spawning BM25 index thread");
400
401 if let Err(e) = graph_handle.join() {
402 tracing::error!("[index_orchestrator: graph thread panicked: {e:?}]");
403 }
404 if let Err(e) = bm25_handle.join() {
405 tracing::error!("[index_orchestrator: BM25 thread panicked: {e:?}]");
406 }
407
408 let final_state = entry_for(root);
409 let mut s = final_state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 s.worker_running = false;
413}
414
415pub fn build_semantic(project_root: &str) {
420 let state = entry_for(project_root);
421 let root = Path::new(project_root);
422
423 {
424 let mut s = state
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 start_component(&mut s.semantic);
428 }
429
430 let bm25_idx = try_load_bm25_index(project_root);
431 match bm25_idx.as_ref() {
432 Some(idx) if idx.doc_count > 0 => {
433 let outcome = crate::core::embedding_index::build_or_update(root, idx);
434 let mut s = state
435 .lock()
436 .unwrap_or_else(std::sync::PoisonError::into_inner);
437 match outcome {
438 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
439 finish_ok(&mut s.semantic);
440 }
441 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
442 finish_ok(&mut s.semantic);
443 s.semantic.note = Some(
444 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
445 .to_string(),
446 );
447 }
448 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
449 ref reason,
450 ) => {
451 s.semantic.state = State::Idle;
452 s.semantic.note = Some(format!("embedding model not available: {reason}"));
453 }
454 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
455 finish_err(
456 &mut s.semantic,
457 "embedding build failed (see logs)".to_string(),
458 );
459 }
460 }
461 }
462 _ => {
463 let mut s = state
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner);
466 s.semantic.state = State::Idle;
467 s.semantic.note =
468 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
469 }
470 }
471}
472
473const MAX_EXTRA_ROOT_BUILDS: usize = 8;
477
478pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
479 let primary = Path::new(primary_root);
480 let mut queue: Vec<String> = Vec::new();
481 for root in extra_roots {
482 if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
483 break;
484 }
485 let rp = Path::new(root);
486 if !rp.is_dir() {
487 continue;
488 }
489 if rp.starts_with(primary) {
491 continue;
492 }
493 if primary.starts_with(rp) {
495 continue;
496 }
497 queue.push(root.clone());
498 }
499 if queue.is_empty() {
500 return;
501 }
502
503 let spawned = std::thread::Builder::new()
512 .name("leanctx-extra-roots".to_string())
513 .stack_size(INDEXER_STACK_BYTES)
514 .spawn(move || {
515 for root in queue {
516 if crate::core::memory_guard::is_under_pressure()
517 || crate::core::memory_guard::abort_requested()
518 {
519 tracing::warn!(
520 "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
521 );
522 break;
523 }
524 if !try_claim_worker(&root) {
525 continue; }
527 nudge_daemon_index(&root);
528 run_build_worker(&root);
529 }
530 });
531 if let Err(e) = spawned {
532 tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
533 }
534}
535
536fn bm25_build_note(
541 doc_count: usize,
542 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
543) -> String {
544 use crate::core::bm25_index::SaveOutcome;
545 match save {
546 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
547 "indexed {doc_count} chunks, {:.1} MB persisted",
548 *compressed_bytes as f64 / 1_048_576.0
549 ),
550 Ok(SaveOutcome::SkippedTooLarge {
551 compressed_bytes,
552 limit_bytes,
553 }) => format!(
554 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
555 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
556 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
557 *compressed_bytes as f64 / 1_048_576.0,
558 *limit_bytes as f64 / 1_048_576.0
559 ),
560 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
561 }
562}
563
564#[derive(Debug, Clone)]
567pub struct Bm25Summary {
568 pub state: &'static str,
569 pub elapsed_ms: Option<u64>,
571 pub note: Option<String>,
572 pub last_error: Option<String>,
573}
574
575#[derive(Debug, Clone)]
577pub struct SemanticSummary {
578 pub state: &'static str,
579 pub elapsed_ms: Option<u64>,
580 pub note: Option<String>,
581 pub last_error: Option<String>,
582}
583
584fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
588 let elapsed_ms = if matches!(c.state, State::Building) {
589 c.started_ms.map(|start| now_ms().saturating_sub(start))
590 } else {
591 c.duration_ms
592 };
593 let state = match c.state {
594 State::Idle => "idle",
595 State::Building => "building",
596 State::Ready => "ready",
597 State::Failed => "failed",
598 };
599 (state, elapsed_ms)
600}
601
602pub fn semantic_summary(project_root: &str) -> SemanticSummary {
603 let entry = entry_for(project_root);
604 let s = entry
605 .lock()
606 .unwrap_or_else(std::sync::PoisonError::into_inner);
607 let c = &s.semantic;
608 let (state, elapsed_ms) = component_elapsed_and_state(c);
609 SemanticSummary {
610 state,
611 elapsed_ms,
612 note: c.note.clone(),
613 last_error: c.last_error.clone(),
614 }
615}
616
617pub fn bm25_summary(project_root: &str) -> Bm25Summary {
618 let entry = entry_for(project_root);
619 let s = entry
620 .lock()
621 .unwrap_or_else(std::sync::PoisonError::into_inner);
622 let c = &s.bm25;
623 let (state, elapsed_ms) = component_elapsed_and_state(c);
624 Bm25Summary {
625 state,
626 elapsed_ms,
627 note: c.note.clone(),
628 last_error: c.last_error.clone(),
629 }
630}
631
632pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
633 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
636}
637
638pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
639 BM25Index::load(Path::new(project_root))
640}
641
642pub fn is_building() -> bool {
644 let map = registry()
645 .lock()
646 .unwrap_or_else(std::sync::PoisonError::into_inner);
647 map.values().any(|entry| {
648 let st = entry
649 .lock()
650 .unwrap_or_else(std::sync::PoisonError::into_inner);
651 matches!(st.bm25.state, State::Building)
652 || matches!(st.graph.state, State::Building)
653 || matches!(st.semantic.state, State::Building)
654 })
655}
656
657#[derive(Debug, Serialize)]
658struct ComponentStatus<'a> {
659 state: &'a str,
660 started_ms: Option<u64>,
661 finished_ms: Option<u64>,
662 duration_ms: Option<u64>,
663 last_error: Option<&'a str>,
664 #[serde(skip_serializing_if = "Option::is_none")]
665 note: Option<&'a str>,
666}
667
668fn component_status(c: &Component) -> ComponentStatus<'_> {
669 ComponentStatus {
670 state: match c.state {
671 State::Idle => "idle",
672 State::Building => "building",
673 State::Ready => "ready",
674 State::Failed => "failed",
675 },
676 started_ms: c.started_ms,
677 finished_ms: c.finished_ms,
678 duration_ms: c.duration_ms,
679 last_error: c.last_error.as_deref(),
680 note: c.note.as_deref(),
681 }
682}
683
684#[derive(Debug, Serialize)]
685struct StatusResponse<'a> {
686 project_root: &'a str,
687 graph_index: ComponentStatus<'a>,
688 bm25_index: ComponentStatus<'a>,
689 semantic_index: ComponentStatus<'a>,
693 disk: DiskStatusAll,
694}
695
696#[derive(Debug, Serialize, Default)]
697pub struct DiskStatus {
698 pub exists: bool,
699 pub size_bytes: Option<u64>,
700 pub file_count: Option<u64>,
701 pub modified_at: Option<String>,
702}
703
704#[derive(Debug, Serialize, Default)]
705pub struct DiskStatusAll {
706 pub graph_index: DiskStatus,
707 pub bm25_index: DiskStatus,
708 pub code_graph: DiskStatus,
709 pub semantic_index: DiskStatus,
713}
714
715fn disk_status_for_graph(project_root: &str) -> DiskStatus {
716 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
721 return DiskStatus::default();
722 };
723 let meta_file = dir.join("graph.meta.json");
724 if !meta_file.exists() {
725 return DiskStatus::default();
726 }
727 let meta = std::fs::metadata(&meta_file).ok();
728 let file_count =
729 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
730 DiskStatus {
731 exists: true,
732 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
733 file_count,
734 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
735 }
736}
737
738fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
739 let root = Path::new(project_root);
740 let path = BM25Index::index_file_path(root);
741 if !path.exists() {
742 return DiskStatus::default();
743 }
744 let meta = std::fs::metadata(&path).ok();
745 DiskStatus {
746 exists: true,
747 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
748 file_count: None,
749 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
750 }
751}
752
753fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
754 let dir = crate::core::property_graph::graph_dir(project_root);
755 let db_path = dir.join("graph.db");
756 if !db_path.exists() {
757 return DiskStatus::default();
758 }
759 let meta = std::fs::metadata(&db_path).ok();
760 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
761 .ok()
762 .and_then(|g| {
763 g.connection()
764 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
765 .ok()
766 .map(|c| c as u64)
767 });
768 DiskStatus {
769 exists: true,
770 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
771 file_count: node_count,
772 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
773 }
774}
775
776fn format_time(t: SystemTime) -> String {
777 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
778 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
779 dt.map_or_else(
780 || format!("{secs}"),
781 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
782 )
783}
784
785pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
786 let root = Path::new(project_root);
787 let dir = crate::core::index_namespace::vectors_dir(root);
788 let bin_path = dir.join("embeddings.bin");
789 if !bin_path.exists() {
790 return DiskStatus::default();
791 }
792 let meta = std::fs::metadata(&bin_path).ok();
793 DiskStatus {
794 exists: true,
795 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
796 file_count: None,
797 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
798 }
799}
800
801pub fn disk_status(project_root: &str) -> DiskStatusAll {
802 DiskStatusAll {
803 graph_index: disk_status_for_graph(project_root),
804 bm25_index: disk_status_for_bm25(project_root),
805 code_graph: disk_status_for_code_graph(project_root),
806 semantic_index: disk_status_for_semantic(project_root),
807 }
808}
809
810pub fn status_json(project_root: &str) -> String {
811 let disk = disk_status(project_root);
815 let state = entry_for(project_root);
816 let s = state
817 .lock()
818 .unwrap_or_else(std::sync::PoisonError::into_inner);
819 let res = StatusResponse {
820 project_root,
821 graph_index: component_status(&s.graph),
822 bm25_index: component_status(&s.bm25),
823 semantic_index: component_status(&s.semantic),
824 disk,
825 };
826 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn status_json_is_valid_json() {
835 let s = status_json("/tmp");
836 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
837 }
838
839 #[test]
840 fn warm_need_classifies_tools() {
841 for light in [
843 "ctx_read",
844 "ctx_shell",
845 "ctx_tree",
846 "ctx_knowledge",
847 "unknown_tool",
848 ] {
849 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
850 }
851 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
853 for heavy in [
854 "ctx_graph",
855 "ctx_callgraph",
856 "ctx_routes",
857 "ctx_repomap",
858 "ctx_impact",
859 "ctx_artifacts",
860 "ctx_semantic_search",
861 "ctx_provider",
862 "ctx_compose",
863 "ctx_explore",
864 "ctx_review",
865 ] {
866 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
867 }
868 }
869
870 #[test]
871 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
872 assert!(!ensure_warm_for_tool("", "ctx_graph"));
873 let tmp = tempfile::tempdir().unwrap();
874 let root = tmp.path().to_string_lossy().to_string();
875 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
876 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
877 }
878
879 #[test]
880 fn ensure_warm_heavy_is_once_per_root() {
881 let tmp = tempfile::tempdir().unwrap();
885 let root = tmp.path().to_string_lossy().to_string();
886 assert!(
887 ensure_warm_for_tool(&root, "ctx_callgraph"),
888 "first heavy warm must signal true"
889 );
890 assert!(
891 !ensure_warm_for_tool(&root, "ctx_callgraph"),
892 "second heavy warm must be deduped to false"
893 );
894 assert!(
895 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
896 "any later heavy tool on the same root is also deduped"
897 );
898 }
899
900 #[test]
901 fn build_note_persisted_reports_size() {
902 let note = bm25_build_note(
903 42,
904 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
905 compressed_bytes: 3 * 1024 * 1024,
906 }),
907 );
908 assert!(
909 note.contains("42 chunks"),
910 "note should report chunk count: {note}"
911 );
912 assert!(
913 note.contains("persisted"),
914 "note should report persistence: {note}"
915 );
916 }
917
918 #[test]
919 fn build_note_too_large_carries_remedy() {
920 let note = bm25_build_note(
921 1000,
922 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
923 compressed_bytes: 600 * 1024 * 1024,
924 limit_bytes: 512 * 1024 * 1024,
925 }),
926 );
927 assert!(
928 note.contains("NOT persisted"),
929 "must flag non-persistence: {note}"
930 );
931 assert!(
932 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
933 "too-large note must carry an actionable remedy: {note}"
934 );
935 }
936
937 #[test]
938 fn build_note_persist_error_is_reported() {
939 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
940 assert!(note.contains("persisting failed"), "note: {note}");
941 assert!(
942 note.contains("disk full"),
943 "note should include the io error: {note}"
944 );
945 }
946
947 #[test]
948 fn bm25_summary_unknown_project_is_idle() {
949 let tmp = tempfile::tempdir().unwrap();
950 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
951 assert_eq!(summary.state, "idle");
952 assert!(summary.note.is_none());
953 assert!(summary.last_error.is_none());
954 }
955
956 #[test]
957 fn extra_roots_skips_subdirs_of_primary() {
958 let tmp = tempfile::tempdir().unwrap();
959 let primary = tmp.path().join("primary");
960 std::fs::create_dir_all(&primary).unwrap();
961 let sub = primary.join("subdir");
962 std::fs::create_dir_all(&sub).unwrap();
963 let external = tmp.path().join("external");
964 std::fs::create_dir_all(&external).unwrap();
965
966 let primary_str = primary.to_string_lossy().to_string();
967 let extra = vec![
968 sub.to_string_lossy().to_string(),
969 external.to_string_lossy().to_string(),
970 ];
971
972 ensure_extra_roots_background(&primary_str, &extra);
974 }
975
976 #[test]
977 fn extra_roots_caps_at_max() {
978 let tmp = tempfile::tempdir().unwrap();
979 let primary = tmp.path().join("primary");
980 std::fs::create_dir_all(&primary).unwrap();
981
982 let mut extra = Vec::new();
983 for i in 0..20 {
984 let d = tmp.path().join(format!("ext-{i}"));
985 std::fs::create_dir_all(&d).unwrap();
986 extra.push(d.to_string_lossy().to_string());
987 }
988
989 let primary_str = primary.to_string_lossy().to_string();
990 ensure_extra_roots_background(&primary_str, &extra);
992 }
993
994 #[test]
995 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
996 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
997 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
998 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
999 assert_ne!(a, b, "lock name must be per-repo");
1000 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1002 let graph = format!(
1005 "graph-idx-{}",
1006 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1007 );
1008 assert_ne!(a, graph, "bm25 and graph locks must be independent");
1009 }
1010}