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