Skip to main content

lean_ctx/core/
index_orchestrator.rs

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    /// Human-readable outcome detail surfaced to operators (e.g. doc count +
27    /// persisted size, or the "not persisted: too large …" remedy). Independent
28    /// of `last_error` so a *successful* build can still carry a warning note.
29    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    /// Set the first time a heavy-index tool lazily pre-warms this root (#152).
49    /// Prevents re-triggering a full rebuild on every subsequent dispatch — the
50    /// tools' own `load_or_build` paths handle staleness from then on.
51    warm_triggered: bool,
52    graph: Component,
53    bm25: Component,
54}
55
56impl ProjectBuild {
57    fn new() -> Self {
58        Self {
59            worker_running: false,
60            warm_triggered: false,
61            graph: Component::new(),
62            bm25: Component::new(),
63        }
64    }
65}
66
67// Lock ordering (see rust/LOCK_ORDERING.md):
68//   L1 = REGISTRY outer Mutex  (the HashMap guard)
69//   L2 = per-project Arc<Mutex<ProjectBuild>>  (inner guard)
70//
71// Invariant: L1 must NEVER be held while locking L2.
72// `entry_for()` enforces this by cloning the Arc and dropping L1 before
73// the caller acquires L2.
74static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
75
76fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
77    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
78}
79
80fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
81    let mut map = registry()
82        .lock()
83        .unwrap_or_else(std::sync::PoisonError::into_inner);
84    map.entry(project_root.to_string())
85        .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
86        .clone()
87}
88
89fn now_ms() -> u64 {
90    SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_millis() as u64
94}
95
96/// Per-repo lock name for serializing the BM25 build across processes, mirroring
97/// the `graph-idx-<hash>` lock (see LOCK_ORDERING.md). The distinct `bm25-` vs
98/// `graph-` prefix keeps the graph and BM25 builds from serializing against each
99/// other while still preventing N processes from rebuilding either in parallel.
100fn bm25_index_lock_name(root: &Path) -> String {
101    format!(
102        "bm25-idx-{}",
103        &crate::core::index_namespace::namespace_hash(root)[..8]
104    )
105}
106
107fn start_component(c: &mut Component) {
108    c.state = State::Building;
109    c.started_ms = Some(now_ms());
110    c.finished_ms = None;
111    c.duration_ms = None;
112    c.last_error = None;
113    c.note = None;
114}
115
116fn finish_ok(c: &mut Component) {
117    c.state = State::Ready;
118    let end = now_ms();
119    c.finished_ms = Some(end);
120    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
121}
122
123fn finish_err(c: &mut Component, e: String) {
124    c.state = State::Failed;
125    let end = now_ms();
126    c.finished_ms = Some(end);
127    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
128    c.last_error = Some(e);
129}
130
131/// The index warmth a tool benefits from. Drives lazy, demand-driven warming
132/// (issue #152) so the server no longer scans the whole project eagerly on every
133/// `initialize` — a session that only uses `ctx_read`/`ctx_shell`/`ctx_tree`
134/// pays zero indexing cost.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum WarmNeed {
137    /// No prebuilt index needed.
138    None,
139    /// Only the resident line-search (trigram) index — cheap, used by `ctx_search`.
140    Search,
141    /// Full project indices (graph + BM25; this also warms the search index).
142    Heavy,
143}
144
145/// Classify a tool by the index warmth it benefits from. Unknown tools default
146/// to [`WarmNeed::None`]; a heavy tool mis-classified as `None` still works — it
147/// just builds its index synchronously on first use instead of being pre-warmed.
148#[must_use]
149pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
150    match tool {
151        "ctx_search" => WarmNeed::Search,
152        // Tools that build/consume the graph, call-graph, BM25 or artifact index.
153        "ctx_graph"
154        | "ctx_callgraph"
155        | "ctx_routes"
156        | "ctx_repomap"
157        | "ctx_impact"
158        | "ctx_artifacts"
159        | "ctx_semantic_search"
160        | "ctx_provider"
161        | "ctx_compose"
162        | "ctx_review" => WarmNeed::Heavy,
163        _ => WarmNeed::None,
164    }
165}
166
167/// Lazily warm the indices a tool needs, deduped per root. Never blocks (all
168/// work is spawned in the background) and is safe to call on every dispatch.
169///
170/// Returns `true` only when this call is the *first* heavy pre-warm for `root`
171/// in this process — the caller can use that signal to warm secondary roots once
172/// without re-reading session state on every dispatch.
173pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
174    if project_root.is_empty() {
175        return false;
176    }
177    match warm_need_for_tool(tool) {
178        WarmNeed::None => false,
179        WarmNeed::Search => {
180            // The search index has its own TTL + background-rebuild dedup, so it
181            // is safe (and cheap) to nudge on every `ctx_search`.
182            crate::core::search_index::ensure_background(project_root, true, false);
183            false
184        }
185        WarmNeed::Heavy => {
186            let entry = entry_for(project_root);
187            let first_warm = {
188                let mut s = entry
189                    .lock()
190                    .unwrap_or_else(std::sync::PoisonError::into_inner);
191                if s.warm_triggered {
192                    false
193                } else {
194                    s.warm_triggered = true;
195                    true
196                }
197            };
198            if first_warm {
199                ensure_all_background(project_root);
200            }
201            first_warm
202        }
203    }
204}
205
206/// Stack size for background index workers. Large enough that deep ASTs and
207/// graph traversals cannot overflow it (the #378 SIGABRT class). The AST walks
208/// are iterative now too, so this is defense-in-depth.
209const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
210
211pub fn ensure_all_background(project_root: &str) {
212    let state = entry_for(project_root);
213    let should_spawn = {
214        let mut s = state
215            .lock()
216            .unwrap_or_else(std::sync::PoisonError::into_inner);
217        if s.worker_running {
218            false
219        } else {
220            s.worker_running = true;
221            true
222        }
223    };
224
225    if !should_spawn {
226        return;
227    }
228
229    let root = project_root.to_string();
230    let indexer = move || {
231        let state = entry_for(&root);
232
233        // Pre-warm the resident line-search index in parallel (own thread,
234        // deduped internally) so the first ctx_search hits the fast path.
235        crate::core::search_index::ensure_background(&root, true, false);
236
237        // Phase 1: Graph index — may produce a content cache from the file walk
238        {
239            let mut s = state
240                .lock()
241                .unwrap_or_else(std::sync::PoisonError::into_inner);
242            start_component(&mut s.graph);
243        }
244        let graph_result = std::panic::catch_unwind(|| {
245            let (idx, content_cache) = graph_index::scan_with_content_cache(&root);
246            // #696 C4: the property graph is the sole store. `save()` mirrors the
247            // freshly scanned index into PG (stamping `graph.meta.json`) in this
248            // same reliable worker, so PG inherits the scan's build reliability —
249            // no separate fire-and-forget mirror thread that dies in short-lived
250            // processes.
251            if let Err(e) = idx.save() {
252                tracing::warn!("[index_orchestrator: graph save failed: {e}]");
253            }
254            (idx, content_cache)
255        });
256        let content_cache = if let Ok((_idx, cache)) = graph_result {
257            let mut s = state
258                .lock()
259                .unwrap_or_else(std::sync::PoisonError::into_inner);
260            finish_ok(&mut s.graph);
261            cache
262        } else {
263            let mut s = state
264                .lock()
265                .unwrap_or_else(std::sync::PoisonError::into_inner);
266            finish_err(&mut s.graph, "graph index build panicked".to_string());
267            HashMap::new()
268        };
269
270        // Phase 2: BM25 index — reuses content from graph scan when available
271        {
272            let mut s = state
273                .lock()
274                .unwrap_or_else(std::sync::PoisonError::into_inner);
275            start_component(&mut s.bm25);
276        }
277        let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
278            let root_pb = Path::new(&root);
279            // Cross-instance build coordination: serialize the (expensive) BM25
280            // build per repo, mirroring the `graph-idx` lock in graph_index. The
281            // graph scan is already guarded this way; BM25 was the one component
282            // left to stampede when N sessions warm the same repo at once. On
283            // lock contention we load the index the holder is producing rather
284            // than running a second full build.
285            let lock_name = bm25_index_lock_name(root_pb);
286            let _lock = crate::core::startup_guard::try_acquire_lock(
287                &lock_name,
288                std::time::Duration::from_millis(800),
289                std::time::Duration::from_mins(3),
290            );
291            if _lock.is_none() {
292                tracing::info!(
293                    "[bm25: another process is building {root} — loading the shared index]"
294                );
295                let idx = BM25Index::load(root_pb).unwrap_or_default();
296                return (idx.doc_count, None);
297            }
298            let idx = if content_cache.is_empty() {
299                BM25Index::load_or_build(root_pb)
300            } else {
301                BM25Index::build_with_content_hint(root_pb, &content_cache)
302            };
303            let outcome = idx.save(root_pb);
304            (idx.doc_count, Some(outcome))
305        }));
306        if let Ok((doc_count, save_res)) = bm {
307            let mut s = state
308                .lock()
309                .unwrap_or_else(std::sync::PoisonError::into_inner);
310            finish_ok(&mut s.bm25);
311            s.bm25.note = Some(match save_res {
312                Some(outcome) => bm25_build_note(doc_count, &outcome),
313                None => format!(
314                    "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
315                ),
316            });
317        } else {
318            let mut s = state
319                .lock()
320                .unwrap_or_else(std::sync::PoisonError::into_inner);
321            finish_err(&mut s.bm25, "bm25 build panicked".to_string());
322        }
323
324        let mut s = state
325            .lock()
326            .unwrap_or_else(std::sync::PoisonError::into_inner);
327        s.worker_running = false;
328    };
329
330    // Indexing parses large ASTs and traverses graphs; give the worker a
331    // generous stack as defense-in-depth against deep-recursion overflow (the
332    // #378 SIGABRT class) and a name so it is identifiable in crash dumps.
333    let spawned = std::thread::Builder::new()
334        .name("leanctx-index".to_string())
335        .stack_size(INDEXER_STACK_BYTES)
336        .spawn(indexer);
337    if spawned.is_err() {
338        // The OS refused a new thread (rare). Clear the in-flight flag so a
339        // later trigger retries instead of assuming a build runs forever.
340        let mut s = state
341            .lock()
342            .unwrap_or_else(std::sync::PoisonError::into_inner);
343        s.worker_running = false;
344    }
345}
346
347/// Ensure background indexing for all extra roots (in addition to the primary).
348/// Each extra root that is not a subdirectory of `primary_root` gets its own
349/// graph + BM25 index. Capped at `MAX_EXTRA_ROOT_BUILDS` to prevent runaway.
350const MAX_EXTRA_ROOT_BUILDS: usize = 8;
351
352pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
353    let primary = Path::new(primary_root);
354    let mut built = 0;
355    for root in extra_roots {
356        if built >= MAX_EXTRA_ROOT_BUILDS {
357            break;
358        }
359        let rp = Path::new(root);
360        if !rp.is_dir() {
361            continue;
362        }
363        // Skip if extra_root is inside primary (already indexed by the primary scan)
364        if rp.starts_with(primary) {
365            continue;
366        }
367        // Skip if primary is inside this extra_root (avoid double-indexing the parent)
368        if primary.starts_with(rp) {
369            continue;
370        }
371        ensure_all_background(root);
372        built += 1;
373    }
374}
375
376/// Build a human-readable outcome note for a finished BM25 build, including the
377/// indexed chunk count and whether the index was persisted to disk. A
378/// "too large" refusal carries the exact remedy so the operator (or agent) is
379/// never left guessing why search/ranking stays cold (issue #249).
380fn bm25_build_note(
381    doc_count: usize,
382    save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
383) -> String {
384    use crate::core::bm25_index::SaveOutcome;
385    match save {
386        Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
387            "indexed {doc_count} chunks, {:.1} MB persisted",
388            *compressed_bytes as f64 / 1_048_576.0
389        ),
390        Ok(SaveOutcome::SkippedTooLarge {
391            compressed_bytes,
392            limit_bytes,
393        }) => format!(
394            "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
395             Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
396             then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
397            *compressed_bytes as f64 / 1_048_576.0,
398            *limit_bytes as f64 / 1_048_576.0
399        ),
400        Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
401    }
402}
403
404/// Lightweight, allocation-frugal snapshot of the BM25 component for the
405/// in-call composer/search messaging. Avoids the heavier [`disk_status`] walk.
406#[derive(Debug, Clone)]
407pub struct Bm25Summary {
408    pub state: &'static str,
409    /// While building: elapsed so far. Otherwise: last build duration.
410    pub elapsed_ms: Option<u64>,
411    pub note: Option<String>,
412    pub last_error: Option<String>,
413}
414
415pub fn bm25_summary(project_root: &str) -> Bm25Summary {
416    let entry = entry_for(project_root);
417    let s = entry
418        .lock()
419        .unwrap_or_else(std::sync::PoisonError::into_inner);
420    let c = &s.bm25;
421    let elapsed_ms = if matches!(c.state, State::Building) {
422        c.started_ms.map(|start| now_ms().saturating_sub(start))
423    } else {
424        c.duration_ms
425    };
426    Bm25Summary {
427        state: match c.state {
428            State::Idle => "idle",
429            State::Building => "building",
430            State::Ready => "ready",
431            State::Failed => "failed",
432        },
433        elapsed_ms,
434        note: c.note.clone(),
435        last_error: c.last_error.clone(),
436    }
437}
438
439pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
440    // Resident cache: avoids re-materializing the index from the property graph
441    // (SQLite query) on every graph-touching query. Returns an in-memory clone.
442    crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
443}
444
445pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
446    BM25Index::load(Path::new(project_root))
447}
448
449/// Returns true if any project is currently building its indices.
450pub fn is_building() -> bool {
451    let map = registry()
452        .lock()
453        .unwrap_or_else(std::sync::PoisonError::into_inner);
454    map.values().any(|entry| {
455        let s = entry
456            .lock()
457            .unwrap_or_else(std::sync::PoisonError::into_inner);
458        matches!(s.bm25.state, State::Building) || matches!(s.graph.state, State::Building)
459    })
460}
461
462#[derive(Debug, Serialize)]
463struct ComponentStatus<'a> {
464    state: &'a str,
465    started_ms: Option<u64>,
466    finished_ms: Option<u64>,
467    duration_ms: Option<u64>,
468    last_error: Option<&'a str>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    note: Option<&'a str>,
471}
472
473fn component_status(c: &Component) -> ComponentStatus<'_> {
474    ComponentStatus {
475        state: match c.state {
476            State::Idle => "idle",
477            State::Building => "building",
478            State::Ready => "ready",
479            State::Failed => "failed",
480        },
481        started_ms: c.started_ms,
482        finished_ms: c.finished_ms,
483        duration_ms: c.duration_ms,
484        last_error: c.last_error.as_deref(),
485        note: c.note.as_deref(),
486    }
487}
488
489#[derive(Debug, Serialize)]
490struct StatusResponse<'a> {
491    project_root: &'a str,
492    graph_index: ComponentStatus<'a>,
493    bm25_index: ComponentStatus<'a>,
494    disk: DiskStatusAll,
495}
496
497#[derive(Debug, Serialize, Default)]
498pub struct DiskStatus {
499    pub exists: bool,
500    pub size_bytes: Option<u64>,
501    pub file_count: Option<u64>,
502    pub modified_at: Option<String>,
503}
504
505#[derive(Debug, Serialize, Default)]
506pub struct DiskStatusAll {
507    pub graph_index: DiskStatus,
508    pub bm25_index: DiskStatus,
509    pub code_graph: DiskStatus,
510}
511
512fn disk_status_for_graph(project_root: &str) -> DiskStatus {
513    // #696 C4: the property graph is the sole store. The logical graph-index
514    // view (file count) is sized/timed by `graph.meta.json`, which the mirror
515    // stamps on every build; `disk_status_for_code_graph` reports the raw
516    // SQLite store (nodes, graph.db) as a distinct facet.
517    let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
518        return DiskStatus::default();
519    };
520    let meta_file = dir.join("graph.meta.json");
521    if !meta_file.exists() {
522        return DiskStatus::default();
523    }
524    let meta = std::fs::metadata(&meta_file).ok();
525    let file_count =
526        graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
527    DiskStatus {
528        exists: true,
529        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
530        file_count,
531        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
532    }
533}
534
535fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
536    let root = Path::new(project_root);
537    let path = BM25Index::index_file_path(root);
538    if !path.exists() {
539        return DiskStatus::default();
540    }
541    let meta = std::fs::metadata(&path).ok();
542    DiskStatus {
543        exists: true,
544        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
545        file_count: None,
546        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
547    }
548}
549
550fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
551    let dir = crate::core::property_graph::graph_dir(project_root);
552    let db_path = dir.join("graph.db");
553    if !db_path.exists() {
554        return DiskStatus::default();
555    }
556    let meta = std::fs::metadata(&db_path).ok();
557    let node_count = crate::core::property_graph::CodeGraph::open(project_root)
558        .ok()
559        .and_then(|g| {
560            g.connection()
561                .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
562                .ok()
563                .map(|c| c as u64)
564        });
565    DiskStatus {
566        exists: true,
567        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
568        file_count: node_count,
569        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
570    }
571}
572
573fn format_time(t: SystemTime) -> String {
574    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
575    let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
576    dt.map_or_else(
577        || format!("{secs}"),
578        |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
579    )
580}
581
582pub fn disk_status(project_root: &str) -> DiskStatusAll {
583    DiskStatusAll {
584        graph_index: disk_status_for_graph(project_root),
585        bm25_index: disk_status_for_bm25(project_root),
586        code_graph: disk_status_for_code_graph(project_root),
587    }
588}
589
590pub fn status_json(project_root: &str) -> String {
591    let state = entry_for(project_root);
592    let s = state
593        .lock()
594        .unwrap_or_else(std::sync::PoisonError::into_inner);
595    let res = StatusResponse {
596        project_root,
597        graph_index: component_status(&s.graph),
598        bm25_index: component_status(&s.bm25),
599        disk: disk_status(project_root),
600    };
601    serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn status_json_is_valid_json() {
610        let s = status_json("/tmp");
611        let _: serde_json::Value = serde_json::from_str(&s).unwrap();
612    }
613
614    #[test]
615    fn warm_need_classifies_tools() {
616        // Lightweight tools must never trigger a project scan (#152).
617        for light in [
618            "ctx_read",
619            "ctx_shell",
620            "ctx_tree",
621            "ctx_knowledge",
622            "unknown_tool",
623        ] {
624            assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
625        }
626        // ctx_search only needs the cheap trigram index.
627        assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
628        // Graph / BM25 consumers need the full warm.
629        for heavy in [
630            "ctx_graph",
631            "ctx_callgraph",
632            "ctx_routes",
633            "ctx_repomap",
634            "ctx_impact",
635            "ctx_artifacts",
636            "ctx_semantic_search",
637            "ctx_provider",
638            "ctx_compose",
639            "ctx_review",
640        ] {
641            assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
642        }
643    }
644
645    #[test]
646    fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
647        // None and Search must return false (no heavy pre-warm), and an empty
648        // root is always a no-op.
649        assert!(!ensure_warm_for_tool("", "ctx_graph"));
650        let tmp = tempfile::tempdir().unwrap();
651        let root = tmp.path().to_string_lossy().to_string();
652        assert!(!ensure_warm_for_tool(&root, "ctx_read"));
653        assert!(!ensure_warm_for_tool(&root, "ctx_search"));
654    }
655
656    #[test]
657    fn ensure_warm_heavy_is_once_per_root() {
658        // The first heavy pre-warm signals `true` (so the caller warms extra
659        // roots once); every subsequent call is a no-op `false`, preventing a
660        // rebuild-on-every-dispatch storm.
661        let tmp = tempfile::tempdir().unwrap();
662        let root = tmp.path().to_string_lossy().to_string();
663        assert!(
664            ensure_warm_for_tool(&root, "ctx_callgraph"),
665            "first heavy warm must signal true"
666        );
667        assert!(
668            !ensure_warm_for_tool(&root, "ctx_callgraph"),
669            "second heavy warm must be deduped to false"
670        );
671        assert!(
672            !ensure_warm_for_tool(&root, "ctx_semantic_search"),
673            "any later heavy tool on the same root is also deduped"
674        );
675    }
676
677    #[test]
678    fn build_note_persisted_reports_size() {
679        let note = bm25_build_note(
680            42,
681            &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
682                compressed_bytes: 3 * 1024 * 1024,
683            }),
684        );
685        assert!(
686            note.contains("42 chunks"),
687            "note should report chunk count: {note}"
688        );
689        assert!(
690            note.contains("persisted"),
691            "note should report persistence: {note}"
692        );
693    }
694
695    #[test]
696    fn build_note_too_large_carries_remedy() {
697        let note = bm25_build_note(
698            1000,
699            &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
700                compressed_bytes: 600 * 1024 * 1024,
701                limit_bytes: 512 * 1024 * 1024,
702            }),
703        );
704        assert!(
705            note.contains("NOT persisted"),
706            "must flag non-persistence: {note}"
707        );
708        assert!(
709            note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
710            "too-large note must carry an actionable remedy: {note}"
711        );
712    }
713
714    #[test]
715    fn build_note_persist_error_is_reported() {
716        let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
717        assert!(note.contains("persisting failed"), "note: {note}");
718        assert!(
719            note.contains("disk full"),
720            "note should include the io error: {note}"
721        );
722    }
723
724    #[test]
725    fn bm25_summary_unknown_project_is_idle() {
726        let tmp = tempfile::tempdir().unwrap();
727        let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
728        assert_eq!(summary.state, "idle");
729        assert!(summary.note.is_none());
730        assert!(summary.last_error.is_none());
731    }
732
733    #[test]
734    fn extra_roots_skips_subdirs_of_primary() {
735        let tmp = tempfile::tempdir().unwrap();
736        let primary = tmp.path().join("primary");
737        std::fs::create_dir_all(&primary).unwrap();
738        let sub = primary.join("subdir");
739        std::fs::create_dir_all(&sub).unwrap();
740        let external = tmp.path().join("external");
741        std::fs::create_dir_all(&external).unwrap();
742
743        let primary_str = primary.to_string_lossy().to_string();
744        let extra = vec![
745            sub.to_string_lossy().to_string(),
746            external.to_string_lossy().to_string(),
747        ];
748
749        // Should not panic; subdirs are skipped, external is attempted
750        ensure_extra_roots_background(&primary_str, &extra);
751    }
752
753    #[test]
754    fn extra_roots_caps_at_max() {
755        let tmp = tempfile::tempdir().unwrap();
756        let primary = tmp.path().join("primary");
757        std::fs::create_dir_all(&primary).unwrap();
758
759        let mut extra = Vec::new();
760        for i in 0..20 {
761            let d = tmp.path().join(format!("ext-{i}"));
762            std::fs::create_dir_all(&d).unwrap();
763            extra.push(d.to_string_lossy().to_string());
764        }
765
766        let primary_str = primary.to_string_lossy().to_string();
767        // Should not spawn more than MAX_EXTRA_ROOT_BUILDS threads
768        ensure_extra_roots_background(&primary_str, &extra);
769    }
770
771    #[test]
772    fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
773        let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
774        let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
775        assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
776        assert_ne!(a, b, "lock name must be per-repo");
777        // Stable for the same repo across calls.
778        assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
779        // Must NOT collide with the graph lock for the same repo, or the two
780        // builds would serialize against each other unnecessarily.
781        let graph = format!(
782            "graph-idx-{}",
783            &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
784        );
785        assert_ne!(a, graph, "bm25 and graph locks must be independent");
786    }
787}