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_review" => WarmNeed::Heavy,
168 _ => WarmNeed::None,
169 }
170}
171
172pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
179 if project_root.is_empty() {
180 return false;
181 }
182 match warm_need_for_tool(tool) {
183 WarmNeed::None => false,
184 WarmNeed::Search => {
185 crate::core::search_index::ensure_background(project_root, true, false);
188 false
189 }
190 WarmNeed::Heavy => {
191 let entry = entry_for(project_root);
192 let first_warm = {
193 let mut s = entry
194 .lock()
195 .unwrap_or_else(std::sync::PoisonError::into_inner);
196 if s.warm_triggered {
197 false
198 } else {
199 s.warm_triggered = true;
200 true
201 }
202 };
203 if first_warm {
204 ensure_all_background(project_root);
205 }
206 first_warm
207 }
208 }
209}
210
211const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
215
216fn nudge_daemon_index(project_root: &str) {
228 if crate::daemon::is_foreground_daemon() {
230 return;
231 }
232 let root = project_root.to_string();
233 let _ = std::thread::Builder::new()
234 .name("leanctx-index-nudge".to_string())
235 .spawn(move || {
236 if !crate::daemon::is_daemon_running() {
237 return;
238 }
239 let Ok(rt) = tokio::runtime::Runtime::new() else {
240 return;
241 };
242 let body = serde_json::json!({ "root": root }).to_string();
243 rt.block_on(async {
244 let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
245 .await;
246 });
247 });
248}
249
250pub fn ensure_all_background(project_root: &str) {
251 let state = entry_for(project_root);
252 let should_spawn = {
253 let mut s = state
254 .lock()
255 .unwrap_or_else(std::sync::PoisonError::into_inner);
256 if s.worker_running {
257 false
258 } else {
259 s.worker_running = true;
260 true
261 }
262 };
263
264 if !should_spawn {
265 return;
266 }
267
268 nudge_daemon_index(project_root);
273
274 let root = project_root.to_string();
275 let indexer = move || {
276 crate::core::search_index::ensure_background(&root, true, false);
279
280 let graph_state = entry_for(&root);
282 let graph_root = root.clone();
283 let graph_handle = std::thread::Builder::new()
284 .name("leanctx-graph".to_string())
285 .stack_size(INDEXER_STACK_BYTES)
286 .spawn(move || {
287 {
288 let mut s = graph_state
289 .lock()
290 .unwrap_or_else(std::sync::PoisonError::into_inner);
291 start_component(&mut s.graph);
292 }
293 let graph_result = std::panic::catch_unwind(|| {
294 let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
295 if let Err(e) = idx.save() {
299 tracing::warn!("[index_orchestrator: graph save failed: {e}]");
300 }
301 });
302 if let Ok(()) = graph_result {
303 let mut s = graph_state
304 .lock()
305 .unwrap_or_else(std::sync::PoisonError::into_inner);
306 finish_ok(&mut s.graph);
307 } else {
308 let mut s = graph_state
309 .lock()
310 .unwrap_or_else(std::sync::PoisonError::into_inner);
311 finish_err(&mut s.graph, "graph index build panicked".to_string());
312 }
313 })
314 .expect("spawning graph index thread");
315
316 let bm25_state = entry_for(&root);
317 let bm25_root = root.clone();
318 let bm25_handle = std::thread::Builder::new()
319 .name("leanctx-bm25".to_string())
320 .stack_size(INDEXER_STACK_BYTES)
321 .spawn(move || {
322 {
323 let mut s = bm25_state
324 .lock()
325 .unwrap_or_else(std::sync::PoisonError::into_inner);
326 start_component(&mut s.bm25);
327 }
328 let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
329 let root_pb = Path::new(&bm25_root);
330 let lock_name = bm25_index_lock_name(root_pb);
333 let _lock = crate::core::startup_guard::try_acquire_lock(
334 &lock_name,
335 std::time::Duration::from_millis(800),
336 std::time::Duration::from_mins(3),
337 );
338 if _lock.is_none() {
339 tracing::info!(
340 "[bm25: another process is building {bm25_root} — loading the shared index]"
341 );
342 let idx = BM25Index::load(root_pb).unwrap_or_default();
343 return (idx.doc_count, None);
344 }
345 let idx = BM25Index::load_or_build(root_pb);
346 let outcome = idx.save(root_pb);
347 (idx.doc_count, Some(outcome))
348 }));
349 if let Ok((doc_count, save_res)) = bm {
350 let mut s = bm25_state
351 .lock()
352 .unwrap_or_else(std::sync::PoisonError::into_inner);
353 finish_ok(&mut s.bm25);
354 s.bm25.note = Some(match save_res {
355 Some(outcome) => bm25_build_note(doc_count, &outcome),
356 None => format!(
357 "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
358 ),
359 });
360 } else {
361 let mut s = bm25_state
362 .lock()
363 .unwrap_or_else(std::sync::PoisonError::into_inner);
364 finish_err(&mut s.bm25, "bm25 build panicked".to_string());
365 }
366 })
367 .expect("spawning BM25 index thread");
368
369 if let Err(e) = graph_handle.join() {
370 tracing::error!("[index_orchestrator: graph thread panicked: {e:?}]");
371 }
372 if let Err(e) = bm25_handle.join() {
373 tracing::error!("[index_orchestrator: BM25 thread panicked: {e:?}]");
374 }
375
376 let final_state = entry_for(&root);
377 let mut s = final_state
378 .lock()
379 .unwrap_or_else(std::sync::PoisonError::into_inner);
380 s.worker_running = false;
381 };
382
383 let spawned = std::thread::Builder::new()
387 .name("leanctx-index".to_string())
388 .stack_size(INDEXER_STACK_BYTES)
389 .spawn(indexer);
390 if spawned.is_err() {
391 let mut s = state
394 .lock()
395 .unwrap_or_else(std::sync::PoisonError::into_inner);
396 s.worker_running = false;
397 }
398}
399
400pub fn build_semantic(project_root: &str) {
405 let state = entry_for(project_root);
406 let root = Path::new(project_root);
407
408 {
409 let mut s = state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 start_component(&mut s.semantic);
413 }
414
415 let bm25_idx = try_load_bm25_index(project_root);
416 match bm25_idx.as_ref() {
417 Some(idx) if idx.doc_count > 0 => {
418 let outcome = crate::core::embedding_index::build_or_update(root, idx);
419 let mut s = state
420 .lock()
421 .unwrap_or_else(std::sync::PoisonError::into_inner);
422 match outcome {
423 crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
424 finish_ok(&mut s.semantic);
425 }
426 crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
427 finish_ok(&mut s.semantic);
428 s.semantic.note = Some(
429 "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
430 .to_string(),
431 );
432 }
433 crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
434 ref reason,
435 ) => {
436 s.semantic.state = State::Idle;
437 s.semantic.note = Some(format!("embedding model not available: {reason}"));
438 }
439 crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
440 finish_err(
441 &mut s.semantic,
442 "embedding build failed (see logs)".to_string(),
443 );
444 }
445 }
446 }
447 _ => {
448 let mut s = state
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 s.semantic.state = State::Idle;
452 s.semantic.note =
453 Some("BM25 index is empty or unavailable — nothing to embed".to_string());
454 }
455 }
456}
457
458const MAX_EXTRA_ROOT_BUILDS: usize = 8;
462
463pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
464 let primary = Path::new(primary_root);
465 let mut built = 0;
466 for root in extra_roots {
467 if built >= MAX_EXTRA_ROOT_BUILDS {
468 break;
469 }
470 let rp = Path::new(root);
471 if !rp.is_dir() {
472 continue;
473 }
474 if rp.starts_with(primary) {
476 continue;
477 }
478 if primary.starts_with(rp) {
480 continue;
481 }
482 ensure_all_background(root);
483 built += 1;
484 }
485}
486
487fn bm25_build_note(
492 doc_count: usize,
493 save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
494) -> String {
495 use crate::core::bm25_index::SaveOutcome;
496 match save {
497 Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
498 "indexed {doc_count} chunks, {:.1} MB persisted",
499 *compressed_bytes as f64 / 1_048_576.0
500 ),
501 Ok(SaveOutcome::SkippedTooLarge {
502 compressed_bytes,
503 limit_bytes,
504 }) => format!(
505 "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
506 Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
507 then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
508 *compressed_bytes as f64 / 1_048_576.0,
509 *limit_bytes as f64 / 1_048_576.0
510 ),
511 Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
512 }
513}
514
515#[derive(Debug, Clone)]
518pub struct Bm25Summary {
519 pub state: &'static str,
520 pub elapsed_ms: Option<u64>,
522 pub note: Option<String>,
523 pub last_error: Option<String>,
524}
525
526#[derive(Debug, Clone)]
528pub struct SemanticSummary {
529 pub state: &'static str,
530 pub elapsed_ms: Option<u64>,
531 pub note: Option<String>,
532 pub last_error: Option<String>,
533}
534
535fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
539 let elapsed_ms = if matches!(c.state, State::Building) {
540 c.started_ms.map(|start| now_ms().saturating_sub(start))
541 } else {
542 c.duration_ms
543 };
544 let state = match c.state {
545 State::Idle => "idle",
546 State::Building => "building",
547 State::Ready => "ready",
548 State::Failed => "failed",
549 };
550 (state, elapsed_ms)
551}
552
553pub fn semantic_summary(project_root: &str) -> SemanticSummary {
554 let entry = entry_for(project_root);
555 let s = entry
556 .lock()
557 .unwrap_or_else(std::sync::PoisonError::into_inner);
558 let c = &s.semantic;
559 let (state, elapsed_ms) = component_elapsed_and_state(c);
560 SemanticSummary {
561 state,
562 elapsed_ms,
563 note: c.note.clone(),
564 last_error: c.last_error.clone(),
565 }
566}
567
568pub fn bm25_summary(project_root: &str) -> Bm25Summary {
569 let entry = entry_for(project_root);
570 let s = entry
571 .lock()
572 .unwrap_or_else(std::sync::PoisonError::into_inner);
573 let c = &s.bm25;
574 let (state, elapsed_ms) = component_elapsed_and_state(c);
575 Bm25Summary {
576 state,
577 elapsed_ms,
578 note: c.note.clone(),
579 last_error: c.last_error.clone(),
580 }
581}
582
583pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
584 crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
587}
588
589pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
590 BM25Index::load(Path::new(project_root))
591}
592
593pub fn is_building() -> bool {
595 let map = registry()
596 .lock()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 map.values().any(|entry| {
599 let st = entry
600 .lock()
601 .unwrap_or_else(std::sync::PoisonError::into_inner);
602 matches!(st.bm25.state, State::Building)
603 || matches!(st.graph.state, State::Building)
604 || matches!(st.semantic.state, State::Building)
605 })
606}
607
608#[derive(Debug, Serialize)]
609struct ComponentStatus<'a> {
610 state: &'a str,
611 started_ms: Option<u64>,
612 finished_ms: Option<u64>,
613 duration_ms: Option<u64>,
614 last_error: Option<&'a str>,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 note: Option<&'a str>,
617}
618
619fn component_status(c: &Component) -> ComponentStatus<'_> {
620 ComponentStatus {
621 state: match c.state {
622 State::Idle => "idle",
623 State::Building => "building",
624 State::Ready => "ready",
625 State::Failed => "failed",
626 },
627 started_ms: c.started_ms,
628 finished_ms: c.finished_ms,
629 duration_ms: c.duration_ms,
630 last_error: c.last_error.as_deref(),
631 note: c.note.as_deref(),
632 }
633}
634
635#[derive(Debug, Serialize)]
636struct StatusResponse<'a> {
637 project_root: &'a str,
638 graph_index: ComponentStatus<'a>,
639 bm25_index: ComponentStatus<'a>,
640 semantic_index: ComponentStatus<'a>,
644 disk: DiskStatusAll,
645}
646
647#[derive(Debug, Serialize, Default)]
648pub struct DiskStatus {
649 pub exists: bool,
650 pub size_bytes: Option<u64>,
651 pub file_count: Option<u64>,
652 pub modified_at: Option<String>,
653}
654
655#[derive(Debug, Serialize, Default)]
656pub struct DiskStatusAll {
657 pub graph_index: DiskStatus,
658 pub bm25_index: DiskStatus,
659 pub code_graph: DiskStatus,
660 pub semantic_index: DiskStatus,
664}
665
666fn disk_status_for_graph(project_root: &str) -> DiskStatus {
667 let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
672 return DiskStatus::default();
673 };
674 let meta_file = dir.join("graph.meta.json");
675 if !meta_file.exists() {
676 return DiskStatus::default();
677 }
678 let meta = std::fs::metadata(&meta_file).ok();
679 let file_count =
680 graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
681 DiskStatus {
682 exists: true,
683 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
684 file_count,
685 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
686 }
687}
688
689fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
690 let root = Path::new(project_root);
691 let path = BM25Index::index_file_path(root);
692 if !path.exists() {
693 return DiskStatus::default();
694 }
695 let meta = std::fs::metadata(&path).ok();
696 DiskStatus {
697 exists: true,
698 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
699 file_count: None,
700 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
701 }
702}
703
704fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
705 let dir = crate::core::property_graph::graph_dir(project_root);
706 let db_path = dir.join("graph.db");
707 if !db_path.exists() {
708 return DiskStatus::default();
709 }
710 let meta = std::fs::metadata(&db_path).ok();
711 let node_count = crate::core::property_graph::CodeGraph::open(project_root)
712 .ok()
713 .and_then(|g| {
714 g.connection()
715 .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
716 .ok()
717 .map(|c| c as u64)
718 });
719 DiskStatus {
720 exists: true,
721 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
722 file_count: node_count,
723 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
724 }
725}
726
727fn format_time(t: SystemTime) -> String {
728 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
729 let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
730 dt.map_or_else(
731 || format!("{secs}"),
732 |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
733 )
734}
735
736pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
737 let root = Path::new(project_root);
738 let dir = crate::core::index_namespace::vectors_dir(root);
739 let bin_path = dir.join("embeddings.bin");
740 if !bin_path.exists() {
741 return DiskStatus::default();
742 }
743 let meta = std::fs::metadata(&bin_path).ok();
744 DiskStatus {
745 exists: true,
746 size_bytes: meta.as_ref().map(std::fs::Metadata::len),
747 file_count: None,
748 modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
749 }
750}
751
752pub fn disk_status(project_root: &str) -> DiskStatusAll {
753 DiskStatusAll {
754 graph_index: disk_status_for_graph(project_root),
755 bm25_index: disk_status_for_bm25(project_root),
756 code_graph: disk_status_for_code_graph(project_root),
757 semantic_index: disk_status_for_semantic(project_root),
758 }
759}
760
761pub fn status_json(project_root: &str) -> String {
762 let disk = disk_status(project_root);
766 let state = entry_for(project_root);
767 let s = state
768 .lock()
769 .unwrap_or_else(std::sync::PoisonError::into_inner);
770 let res = StatusResponse {
771 project_root,
772 graph_index: component_status(&s.graph),
773 bm25_index: component_status(&s.bm25),
774 semantic_index: component_status(&s.semantic),
775 disk,
776 };
777 serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn status_json_is_valid_json() {
786 let s = status_json("/tmp");
787 let _: serde_json::Value = serde_json::from_str(&s).unwrap();
788 }
789
790 #[test]
791 fn warm_need_classifies_tools() {
792 for light in [
794 "ctx_read",
795 "ctx_shell",
796 "ctx_tree",
797 "ctx_knowledge",
798 "unknown_tool",
799 ] {
800 assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
801 }
802 assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
804 for heavy in [
805 "ctx_graph",
806 "ctx_callgraph",
807 "ctx_routes",
808 "ctx_repomap",
809 "ctx_impact",
810 "ctx_artifacts",
811 "ctx_semantic_search",
812 "ctx_provider",
813 "ctx_compose",
814 "ctx_review",
815 ] {
816 assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
817 }
818 }
819
820 #[test]
821 fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
822 assert!(!ensure_warm_for_tool("", "ctx_graph"));
823 let tmp = tempfile::tempdir().unwrap();
824 let root = tmp.path().to_string_lossy().to_string();
825 assert!(!ensure_warm_for_tool(&root, "ctx_read"));
826 assert!(!ensure_warm_for_tool(&root, "ctx_search"));
827 }
828
829 #[test]
830 fn ensure_warm_heavy_is_once_per_root() {
831 let tmp = tempfile::tempdir().unwrap();
835 let root = tmp.path().to_string_lossy().to_string();
836 assert!(
837 ensure_warm_for_tool(&root, "ctx_callgraph"),
838 "first heavy warm must signal true"
839 );
840 assert!(
841 !ensure_warm_for_tool(&root, "ctx_callgraph"),
842 "second heavy warm must be deduped to false"
843 );
844 assert!(
845 !ensure_warm_for_tool(&root, "ctx_semantic_search"),
846 "any later heavy tool on the same root is also deduped"
847 );
848 }
849
850 #[test]
851 fn build_note_persisted_reports_size() {
852 let note = bm25_build_note(
853 42,
854 &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
855 compressed_bytes: 3 * 1024 * 1024,
856 }),
857 );
858 assert!(
859 note.contains("42 chunks"),
860 "note should report chunk count: {note}"
861 );
862 assert!(
863 note.contains("persisted"),
864 "note should report persistence: {note}"
865 );
866 }
867
868 #[test]
869 fn build_note_too_large_carries_remedy() {
870 let note = bm25_build_note(
871 1000,
872 &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
873 compressed_bytes: 600 * 1024 * 1024,
874 limit_bytes: 512 * 1024 * 1024,
875 }),
876 );
877 assert!(
878 note.contains("NOT persisted"),
879 "must flag non-persistence: {note}"
880 );
881 assert!(
882 note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
883 "too-large note must carry an actionable remedy: {note}"
884 );
885 }
886
887 #[test]
888 fn build_note_persist_error_is_reported() {
889 let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
890 assert!(note.contains("persisting failed"), "note: {note}");
891 assert!(
892 note.contains("disk full"),
893 "note should include the io error: {note}"
894 );
895 }
896
897 #[test]
898 fn bm25_summary_unknown_project_is_idle() {
899 let tmp = tempfile::tempdir().unwrap();
900 let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
901 assert_eq!(summary.state, "idle");
902 assert!(summary.note.is_none());
903 assert!(summary.last_error.is_none());
904 }
905
906 #[test]
907 fn extra_roots_skips_subdirs_of_primary() {
908 let tmp = tempfile::tempdir().unwrap();
909 let primary = tmp.path().join("primary");
910 std::fs::create_dir_all(&primary).unwrap();
911 let sub = primary.join("subdir");
912 std::fs::create_dir_all(&sub).unwrap();
913 let external = tmp.path().join("external");
914 std::fs::create_dir_all(&external).unwrap();
915
916 let primary_str = primary.to_string_lossy().to_string();
917 let extra = vec![
918 sub.to_string_lossy().to_string(),
919 external.to_string_lossy().to_string(),
920 ];
921
922 ensure_extra_roots_background(&primary_str, &extra);
924 }
925
926 #[test]
927 fn extra_roots_caps_at_max() {
928 let tmp = tempfile::tempdir().unwrap();
929 let primary = tmp.path().join("primary");
930 std::fs::create_dir_all(&primary).unwrap();
931
932 let mut extra = Vec::new();
933 for i in 0..20 {
934 let d = tmp.path().join(format!("ext-{i}"));
935 std::fs::create_dir_all(&d).unwrap();
936 extra.push(d.to_string_lossy().to_string());
937 }
938
939 let primary_str = primary.to_string_lossy().to_string();
940 ensure_extra_roots_background(&primary_str, &extra);
942 }
943
944 #[test]
945 fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
946 let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
947 let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
948 assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
949 assert_ne!(a, b, "lock name must be per-repo");
950 assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
952 let graph = format!(
955 "graph-idx-{}",
956 &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
957 );
958 assert_ne!(a, graph, "bm25 and graph locks must be independent");
959 }
960}