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    /// Cleared on worker claim; set when the graph phase of this run finishes.
49    /// Used by CLI wait loops so they do not exit during the claim→start gap
50    /// or the graph→BM25 handoff (search pre-warm keeps `worker_running`).
51    graph_run_done: bool,
52    /// Cleared on worker claim; set when the BM25 phase of this run finishes.
53    bm25_run_done: bool,
54    /// Set the first time a heavy-index tool lazily pre-warms this root (#152).
55    /// Prevents re-triggering a full rebuild on every subsequent dispatch — the
56    /// tools' own `load_or_build` paths handle staleness from then on.
57    warm_triggered: bool,
58    graph: Component,
59    bm25: Component,
60    /// Dense embedding index (semantic search). Built after BM25 as Phase 3.
61    /// Tracked separately so the orchestrator does not block on a missing ONNX
62    /// model — the status lets users see why semantic stays cold (#249).
63    semantic: Component,
64}
65
66impl ProjectBuild {
67    fn new() -> Self {
68        Self {
69            worker_running: false,
70            graph_run_done: true,
71            bm25_run_done: true,
72            warm_triggered: false,
73            graph: Component::new(),
74            bm25: Component::new(),
75            semantic: Component::new(),
76        }
77    }
78}
79
80// Lock ordering (see rust/LOCK_ORDERING.md):
81//   L1 = REGISTRY outer Mutex  (the HashMap guard)
82//   L2 = per-project Arc<Mutex<ProjectBuild>>  (inner guard)
83//
84// Invariant: L1 must NEVER be held while locking L2.
85// `entry_for()` enforces this by cloning the Arc and dropping L1 before
86// the caller acquires L2.
87static REGISTRY: OnceLock<Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>>> = OnceLock::new();
88
89fn registry() -> &'static Mutex<HashMap<String, Arc<Mutex<ProjectBuild>>>> {
90    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93fn entry_for(project_root: &str) -> Arc<Mutex<ProjectBuild>> {
94    let mut map = registry()
95        .lock()
96        .unwrap_or_else(std::sync::PoisonError::into_inner);
97    map.entry(project_root.to_string())
98        .or_insert_with(|| Arc::new(Mutex::new(ProjectBuild::new())))
99        .clone()
100}
101
102fn now_ms() -> u64 {
103    SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .unwrap_or_default()
106        .as_millis() as u64
107}
108
109/// Per-repo lock name for serializing the BM25 build across processes, mirroring
110/// the `graph-idx-<hash>` lock (see LOCK_ORDERING.md). The distinct `bm25-` vs
111/// `graph-` prefix keeps the graph and BM25 builds from serializing against each
112/// other while still preventing N processes from rebuilding either in parallel.
113fn bm25_index_lock_name(root: &Path) -> String {
114    format!(
115        "bm25-idx-{}",
116        &crate::core::index_namespace::namespace_hash(root)[..8]
117    )
118}
119
120/// Per-repo lock name for the semantic (embedding) build. Two concurrent builds
121/// would each checkpoint `embeddings.bin` and overwrite the other's progress.
122fn semantic_index_lock_name(root: &Path) -> String {
123    format!(
124        "semantic-idx-{}",
125        &crate::core::index_namespace::namespace_hash(root)[..8]
126    )
127}
128
129/// A CPU-only embed of a large corpus can run for hours; a crashed holder is
130/// reclaimed at once through its PID, so this only bounds a frozen process.
131const SEMANTIC_LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_hours(24);
132
133fn start_component(c: &mut Component) {
134    c.state = State::Building;
135    c.started_ms = Some(now_ms());
136    c.finished_ms = None;
137    c.duration_ms = None;
138    c.last_error = None;
139    c.note = None;
140}
141
142fn finish_ok(c: &mut Component) {
143    c.state = State::Ready;
144    let end = now_ms();
145    c.finished_ms = Some(end);
146    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
147}
148
149fn finish_err(c: &mut Component, e: String) {
150    c.state = State::Failed;
151    let end = now_ms();
152    c.finished_ms = Some(end);
153    c.duration_ms = c.started_ms.map(|s| end.saturating_sub(s));
154    c.last_error = Some(e);
155}
156
157/// The index warmth a tool benefits from. Drives lazy, demand-driven warming
158/// (issue #152) so the server no longer scans the whole project eagerly on every
159/// `initialize` — a session that only uses `ctx_read`/`ctx_shell`/`ctx_tree`
160/// pays zero indexing cost.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum WarmNeed {
163    /// No prebuilt index needed.
164    None,
165    /// Only the resident line-search (trigram) index — cheap, used by `ctx_search`.
166    Search,
167    /// Full project indices (graph + BM25; this also warms the search index).
168    Heavy,
169}
170
171/// Classify a tool by the index warmth it benefits from. Unknown tools default
172/// to [`WarmNeed::None`]; a heavy tool mis-classified as `None` still works — it
173/// just builds its index synchronously on first use instead of being pre-warmed.
174#[must_use]
175pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
176    match tool {
177        "ctx_search" => WarmNeed::Search,
178        // Tools that build/consume the graph, call-graph, BM25 or artifact index.
179        "ctx_graph" | "ctx_callgraph" | "ctx_artifacts" | "ctx_provider" | "ctx_compose" => {
180            WarmNeed::Heavy
181        }
182        _ => WarmNeed::None,
183    }
184}
185
186/// Lazily warm the indices a tool needs, deduped per root. Never blocks (all
187/// work is spawned in the background) and is safe to call on every dispatch.
188///
189/// Returns `true` only when this call is the *first* heavy pre-warm for `root`
190/// in this process — the caller can use that signal to warm secondary roots once
191/// without re-reading session state on every dispatch.
192pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
193    if project_root.is_empty() {
194        return false;
195    }
196    match warm_need_for_tool(tool) {
197        WarmNeed::None => false,
198        WarmNeed::Search => {
199            // The search index has its own TTL + background-rebuild dedup, so it
200            // is safe (and cheap) to nudge on every `ctx_search`.
201            crate::core::search_index::ensure_background(project_root, true, false);
202            false
203        }
204        WarmNeed::Heavy => {
205            let entry = entry_for(project_root);
206            let first_warm = {
207                let mut s = entry
208                    .lock()
209                    .unwrap_or_else(std::sync::PoisonError::into_inner);
210                if s.warm_triggered {
211                    false
212                } else {
213                    s.warm_triggered = true;
214                    true
215                }
216            };
217            if first_warm {
218                ensure_all_background(project_root);
219            }
220            first_warm
221        }
222    }
223}
224
225/// Stack size for background index workers. Large enough that deep ASTs and
226/// graph traversals cannot overflow it (the #378 SIGABRT class). The AST walks
227/// are iterative now too, so this is defense-in-depth.
228const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
229
230/// Fire-and-forget: ask a running daemon to own the index build for `root`
231/// (#460, shared-indexer-daemon / thin clients).
232///
233/// The daemon is the single long-lived, machine-wide indexer. Once it holds the
234/// per-repo `graph-idx`/`bm25-idx` build locks, every session load-shares its
235/// on-disk result instead of each running a full scan during a cold boot wave —
236/// turning N simultaneous index passes into ~one. Strictly additive and
237/// best-effort: it runs on its own thread (no ambient runtime to nest into), is
238/// skipped when we *are* the daemon or when no daemon is reachable, and never
239/// blocks the caller. The local build started right after remains the fallback,
240/// so indexing always works with no daemon present.
241fn nudge_daemon_index(project_root: &str) {
242    // The daemon must never delegate the build to itself.
243    if crate::daemon::is_foreground_daemon() {
244        return;
245    }
246    let root = project_root.to_string();
247    let _ = std::thread::Builder::new()
248        .name("leanctx-index-nudge".to_string())
249        .spawn(move || {
250            if !crate::daemon::is_daemon_running() {
251                return;
252            }
253            let Ok(rt) = tokio::runtime::Runtime::new() else {
254                return;
255            };
256            let body = serde_json::json!({ "root": root }).to_string();
257            rt.block_on(async {
258                let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
259                    .await;
260            });
261        });
262}
263
264/// Try to claim the per-root build slot. Returns `false` when a worker is
265/// already running for `project_root` (the claim is released by the build
266/// worker itself when it finishes).
267fn try_claim_worker(project_root: &str) -> bool {
268    let state = entry_for(project_root);
269    let mut s = state
270        .lock()
271        .unwrap_or_else(std::sync::PoisonError::into_inner);
272    if s.worker_running {
273        false
274    } else {
275        s.worker_running = true;
276        s.graph_run_done = false;
277        s.bm25_run_done = false;
278        true
279    }
280}
281
282pub fn ensure_all_background(project_root: &str) {
283    if !try_claim_worker(project_root) {
284        return;
285    }
286
287    // #460: hand the build to the daemon (the single machine-wide indexer) when
288    // one is running and we aren't it. Deduped naturally — we only reach here
289    // when this process is actually about to start a build. Purely additive: the
290    // local build below still runs and load-shares via the per-repo locks.
291    //
292    // #735 exception: with a per-run CLI filter overlay active, the daemon
293    // (which builds with *its* config, not the overlay) could overwrite the
294    // filtered result — the one-off run keeps the build local instead.
295    if !crate::core::index_filter::cli_overlay_active() {
296        nudge_daemon_index(project_root);
297    }
298
299    let state = entry_for(project_root);
300    let root = project_root.to_string();
301    let indexer = move || run_build_worker(&root);
302
303    // Indexing parses large ASTs and traverses graphs; give the worker a
304    // generous stack as defense-in-depth against deep-recursion overflow (the
305    // #378 SIGABRT class) and a name so it is identifiable in crash dumps.
306    let spawned = std::thread::Builder::new()
307        .name("leanctx-index".to_string())
308        .stack_size(INDEXER_STACK_BYTES)
309        .spawn(indexer);
310    if spawned.is_err() {
311        // The OS refused a new thread (rare). Clear the in-flight flag so a
312        // later trigger retries instead of assuming a build runs forever.
313        let mut s = state
314            .lock()
315            .unwrap_or_else(std::sync::PoisonError::into_inner);
316        s.worker_running = false;
317    }
318}
319
320/// Actual per-root build work. Heavy phases run in a fixed, memory-bounded order:
321/// graph → reclaim → BM25 → reclaim → resident search pre-warm. Blocking — callers
322/// own the threading. Requires the caller to have claimed the worker slot via
323/// [`try_claim_worker`]; releases it on exit.
324fn run_build_worker(root: &str) {
325    // The build's project is the root it indexes: its files are the
326    // project's own, not unknown stored text (pz21 §1.4), whatever the
327    // process directory of the detached worker is.
328    crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
329        std::cell::RefCell::new(Some(std::path::PathBuf::from(root))),
330        || run_build_worker_in_project(root),
331    );
332}
333
334fn run_build_worker_in_project(root: &str) {
335    // Released on every exit, a panic outside the per-phase `catch_unwind`
336    // included: `index build` waits for the slot without a deadline, so a slot
337    // left claimed would hang the CLI instead of reporting the failure.
338    let _slot = WorkerSlot(root.to_string());
339
340    // #790: start memory guardian for daemon-spawned builds. Previously only
341    // the CLI path (index_cmd.rs) started the guardian, leaving background
342    // builds (MCP `ensure_all_background`, watcher, graph_coordinator) to run
343    // without RSS pressure checks — the guardian's abort/pressure flags never
344    // fired. `start_guard` is idempotent: a second call within the same
345    // process is a no-op.
346    start_index_memory_guard("build_worker");
347
348    // Graph, BM25, and the resident search index each retain substantial live
349    // state. Running them concurrently defeats cache eviction because active
350    // builders still own their allocations. Keep phases strictly ordered and
351    // reclaim between them (#918); intra-phase Rayon parallelism remains intact.
352
353    let graph_state = entry_for(root);
354    let graph_root = root.to_string();
355    let build_graph = move || {
356        {
357            let mut s = graph_state
358                .lock()
359                .unwrap_or_else(std::sync::PoisonError::into_inner);
360            start_component(&mut s.graph);
361        }
362        // Indeterminate while the scan walks the tree; the scan reports its
363        // file counts once it knows them. Guard clears on exit.
364        let guard = crate::core::index_progress::ProgressGuard::new(
365            graph_root.clone(),
366            crate::core::index_progress::IndexComponent::Graph,
367        );
368        guard.report(0, 0);
369        let graph_result = std::panic::catch_unwind(|| {
370            let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
371            // scan_with_content_cache persists the freshly built ProjectIndex
372            // before returning it; mirroring the same unchanged index again here
373            // only repeats the property-graph mirror.
374            crate::core::code_health::persist::refresh_if_stale(&graph_root, &idx);
375        });
376        if graph_result.is_ok() {
377            // A resident call graph predates the rebuilt index: the next
378            // request compares the persisted one with the files again.
379            crate::core::call_graph::CallGraph::invalidate(&graph_root);
380            // Evidence-graded call/implements edges on top of the fresh
381            // structure; a no-op unless a semantic backend can contribute.
382            let _ = std::panic::catch_unwind(|| {
383                crate::core::graph_enricher::refresh_semantic_edges_in_background(&graph_root);
384            });
385        }
386        drop(guard);
387        crate::core::telemetry_features::record(
388            crate::core::telemetry_features::Feature::GraphIndex,
389            graph_result.is_ok(),
390        );
391        {
392            let mut s = graph_state
393                .lock()
394                .unwrap_or_else(std::sync::PoisonError::into_inner);
395            if let Ok(()) = graph_result {
396                finish_ok(&mut s.graph);
397            } else {
398                finish_err(&mut s.graph, "graph index build panicked".to_string());
399            }
400            s.graph_run_done = true;
401        }
402    };
403
404    let bm25_state = entry_for(root);
405    let bm25_root = root.to_string();
406    let build_bm25 = move || {
407        {
408            let mut s = bm25_state
409                .lock()
410                .unwrap_or_else(std::sync::PoisonError::into_inner);
411            start_component(&mut s.bm25);
412        }
413        let _progress = crate::core::index_progress::ProgressGuard::new(
414            bm25_root.clone(),
415            crate::core::index_progress::IndexComponent::Bm25,
416        );
417        let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
418            let root_pb = Path::new(&bm25_root);
419            let lock_name = bm25_index_lock_name(root_pb);
420            let _lock = crate::core::startup_guard::try_acquire_lock(
421                &lock_name,
422                std::time::Duration::from_millis(800),
423                std::time::Duration::from_mins(3),
424            );
425            if _lock.is_none() {
426                tracing::info!(
427                    "[bm25: another process is building {bm25_root} — loading the shared index]"
428                );
429                let idx = BM25Index::load(root_pb).unwrap_or_default();
430                return (idx.doc_count, None);
431            }
432            let idx = BM25Index::load_or_build(root_pb);
433            let outcome = idx.save(root_pb);
434            (idx.doc_count, Some(outcome))
435        }));
436        // A build another process owns is not counted here; that one counts it.
437        if !matches!(bm, Ok((_, None))) {
438            crate::core::telemetry_features::record(
439                crate::core::telemetry_features::Feature::Bm25Index,
440                bm.is_ok(),
441            );
442        }
443        {
444            let mut s = bm25_state
445                .lock()
446                .unwrap_or_else(std::sync::PoisonError::into_inner);
447            if let Ok((doc_count, save_res)) = bm {
448                finish_ok(&mut s.bm25);
449                s.bm25.note = Some(match save_res {
450                    Some(outcome) => bm25_build_note(doc_count, &outcome),
451                    None => format!(
452                        "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
453                    ),
454                });
455            } else {
456                finish_err(&mut s.bm25, "bm25 build panicked".to_string());
457            }
458            s.bm25_run_done = true;
459        }
460    };
461
462    run_memory_bounded_phases(
463        build_graph,
464        build_bm25,
465        || crate::core::search_index::ensure_background(root, true, false),
466        || {
467            crate::core::content_cache::clear();
468            crate::core::memory_guard::force_purge();
469        },
470    );
471
472    // Post-build memory reclamation: the parallel build allocates large
473    // transient structures (file contents, token vectors, inverted postings).
474    // After merge+save these are freed, but jemalloc retains dirty pages in
475    // its arena — inflating RSS far past the logical working set. Purging
476    // immediately returns those pages to the OS, keeping the serve process
477    // close to the configured max_ram_percent target.
478    crate::core::content_cache::trim_oldest_percent(75);
479    crate::core::memory_guard::force_purge();
480}
481
482/// A claimed per-root build slot (see [`try_claim_worker`]); dropping it
483/// releases the slot.
484struct WorkerSlot(String);
485
486impl Drop for WorkerSlot {
487    fn drop(&mut self) {
488        let state = entry_for(&self.0);
489        let mut s = state
490            .lock()
491            .unwrap_or_else(std::sync::PoisonError::into_inner);
492        s.worker_running = false;
493    }
494}
495
496/// Execute index phases without overlapping their retained working sets.
497/// Kept as a small pure coordinator so ordering remains regression-testable.
498fn run_memory_bounded_phases<G, B, S, R>(graph: G, bm25: B, search: S, mut reclaim: R)
499where
500    G: FnOnce(),
501    B: FnOnce(),
502    S: FnOnce(),
503    R: FnMut(),
504{
505    graph();
506    reclaim();
507    bm25();
508    reclaim();
509    search();
510}
511
512/// Build only the semantic (dense embedding) index from the existing BM25 index.
513/// The BM25 index must already exist on disk — this function loads it and runs
514/// `embedding_index::build_or_update`. Updates the in-memory semantic component
515/// state on completion.
516pub fn build_semantic(project_root: &str) {
517    let state = entry_for(project_root);
518    let root = Path::new(project_root);
519
520    let Some(_lock) = crate::core::startup_guard::try_acquire_lock(
521        &semantic_index_lock_name(root),
522        std::time::Duration::ZERO,
523        SEMANTIC_LOCK_STALE_AFTER,
524    ) else {
525        let mut s = state
526            .lock()
527            .unwrap_or_else(std::sync::PoisonError::into_inner);
528        s.semantic.note = Some(
529            "another lean-ctx process is already building the semantic index for this project; \
530             it saves progress as it goes — check `lean-ctx index status` later"
531                .to_string(),
532        );
533        return;
534    };
535
536    {
537        let mut s = state
538            .lock()
539            .unwrap_or_else(std::sync::PoisonError::into_inner);
540        start_component(&mut s.semantic);
541    }
542
543    // Guard clears progress on every exit (incl. early returns / panics).
544    let _progress = crate::core::index_progress::ProgressGuard::new(
545        project_root.to_string(),
546        crate::core::index_progress::IndexComponent::Semantic,
547    );
548
549    let bm25_idx = try_load_bm25_index(project_root);
550    match bm25_idx.as_ref() {
551        Some(idx) if idx.doc_count > 0 => {
552            let outcome = crate::core::embedding_index::build_or_update(root, idx);
553            match outcome {
554                crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
555                    crate::core::telemetry_features::record(
556                        crate::core::telemetry_features::Feature::SemanticIndex,
557                        true,
558                    );
559                }
560                crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
561                    crate::core::telemetry_features::record(
562                        crate::core::telemetry_features::Feature::SemanticIndex,
563                        false,
564                    );
565                }
566                _ => {}
567            }
568            let mut s = state
569                .lock()
570                .unwrap_or_else(std::sync::PoisonError::into_inner);
571            match outcome {
572                crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
573                    finish_ok(&mut s.semantic);
574                }
575                crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
576                    finish_ok(&mut s.semantic);
577                    s.semantic.note = Some(
578                        "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
579                            .to_string(),
580                    );
581                }
582                crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
583                    ref reason,
584                ) => {
585                    // Not a hard failure — semantic is optional. Surface the reason
586                    // so users know why dense search is cold (#249).
587                    s.semantic.state = State::Idle;
588                    s.semantic.note = Some(format!("embedding model not available: {reason}"));
589                }
590                crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
591                    finish_err(
592                        &mut s.semantic,
593                        "embedding build failed (see logs)".to_string(),
594                    );
595                }
596            }
597        }
598        _ => {
599            let mut s = state
600                .lock()
601                .unwrap_or_else(std::sync::PoisonError::into_inner);
602            // No BM25 docs → nothing to embed. Leave semantic Idle with a note.
603            s.semantic.state = State::Idle;
604            s.semantic.note =
605                Some("BM25 index is empty or unavailable — nothing to embed".to_string());
606        }
607    }
608}
609
610/// Build the semantic index on a background thread, after the graph + BM25 run
611/// when one is in flight or the BM25 index is missing. For MCP calls: a CPU
612/// embed of a large corpus outlives any tool-call timeout, and starting the
613/// embed before BM25 is on disk would find nothing to embed.
614pub fn build_semantic_background(project_root: &str) {
615    let root = project_root.to_string();
616    let spawned = std::thread::Builder::new()
617        .name("leanctx-semantic".to_string())
618        .stack_size(INDEXER_STACK_BYTES)
619        .spawn(move || {
620            start_index_memory_guard("semantic_build");
621            if !disk_status_for_bm25(&root).exists {
622                ensure_all_background(&root);
623            }
624            while progress_view(&root).graph_bm25_active() {
625                std::thread::sleep(std::time::Duration::from_millis(250));
626            }
627            build_semantic(&root);
628        });
629    if let Err(e) = spawned {
630        tracing::warn!("[index_orchestrator: could not spawn semantic worker: {e}]");
631    }
632}
633
634/// Start the memory guardian for an index build. Idempotent per process: the
635/// first `start_guard` call owns the callback, so a daemon whose first request
636/// is `/v1/index/ensure` gets only this one. It must reach the same
637/// process-global caches as the server callback, or the daemon cannot shed its
638/// resident search indexes under pressure (#2075).
639fn start_index_memory_guard(label: &'static str) {
640    crate::core::memory_guard::start_guard(std::sync::Arc::new(move |level| {
641        tracing::warn!("[{label}] memory pressure: {level:?} — background tasks will throttle");
642        let reclaimed = crate::core::eviction_orchestrator::on_memory_pressure(level);
643        crate::core::memory_guard::force_purge();
644        reclaimed
645    }));
646}
647
648/// Ensure background indexing for all extra roots (in addition to the primary).
649/// Each extra root that is not a subdirectory of `primary_root` gets its own
650/// graph + BM25 index. Capped at `MAX_EXTRA_ROOT_BUILDS` to prevent runaway.
651const MAX_EXTRA_ROOT_BUILDS: usize = 8;
652
653pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
654    let primary = Path::new(primary_root);
655    let mut queue: Vec<String> = Vec::new();
656    for root in extra_roots {
657        if queue.len() >= MAX_EXTRA_ROOT_BUILDS {
658            break;
659        }
660        let rp = Path::new(root);
661        if !rp.is_dir() {
662            continue;
663        }
664        // Skip if extra_root is inside primary (already indexed by the primary scan)
665        if rp.starts_with(primary) {
666            continue;
667        }
668        // Skip if primary is inside this extra_root (avoid double-indexing the parent)
669        if primary.starts_with(rp) {
670            continue;
671        }
672        queue.push(root.clone());
673    }
674    if queue.is_empty() {
675        return;
676    }
677
678    // #685: build extra roots *sequentially* on one supervisor thread. The old
679    // per-root `ensure_all_background` fan-out ran up to MAX_EXTRA_ROOT_BUILDS
680    // graph+BM25 pairs concurrently (each with rayon pools inside) — on the
681    // reported multi-root setup (1M+ files across 6+ roots) the combined
682    // transient build state outran the guardian to 75 GB RSS. One root at a
683    // time keeps peak memory bounded to a single build while still warming
684    // every root; the guardian check between roots stops the queue as soon as
685    // pressure appears.
686    let spawned = std::thread::Builder::new()
687        .name("leanctx-extra-roots".to_string())
688        .stack_size(INDEXER_STACK_BYTES)
689        .spawn(move || {
690            for root in queue {
691                if crate::core::memory_guard::is_under_pressure()
692                    || crate::core::memory_guard::abort_requested()
693                {
694                    tracing::warn!(
695                        "[index_orchestrator: skipping remaining extra-root builds under memory pressure]"
696                    );
697                    break;
698                }
699                if !try_claim_worker(&root) {
700                    continue; // already building elsewhere
701                }
702                nudge_daemon_index(&root);
703                run_build_worker(&root);
704            }
705        });
706    if let Err(e) = spawned {
707        tracing::warn!("[index_orchestrator: could not spawn extra-roots worker: {e}]");
708    }
709}
710
711/// Build a human-readable outcome note for a finished BM25 build, including the
712/// indexed chunk count and whether the index was persisted to disk. A
713/// "too large" refusal carries the exact remedy so the operator (or agent) is
714/// never left guessing why search/ranking stays cold (issue #249).
715fn bm25_build_note(
716    doc_count: usize,
717    save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
718) -> String {
719    use crate::core::bm25_index::SaveOutcome;
720    match save {
721        Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
722            "indexed {doc_count} chunks, {:.1} MB persisted",
723            *compressed_bytes as f64 / 1_048_576.0
724        ),
725        Ok(SaveOutcome::SkippedTooLarge {
726            compressed_bytes,
727            limit_bytes,
728        }) => format!(
729            "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
730             Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
731             then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
732            *compressed_bytes as f64 / 1_048_576.0,
733            *limit_bytes as f64 / 1_048_576.0
734        ),
735        Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
736    }
737}
738
739/// Lightweight, allocation-frugal snapshot of the BM25 component for the
740/// in-call composer/search messaging. Avoids the heavier [`disk_status`] walk.
741#[derive(Debug, Clone)]
742pub struct Bm25Summary {
743    pub state: &'static str,
744    /// While building: elapsed so far. Otherwise: last build duration.
745    pub elapsed_ms: Option<u64>,
746    pub note: Option<String>,
747    pub last_error: Option<String>,
748}
749
750/// Lightweight snapshot of the semantic (dense embedding) component.
751#[derive(Debug, Clone)]
752pub struct SemanticSummary {
753    pub state: &'static str,
754    pub elapsed_ms: Option<u64>,
755    pub note: Option<String>,
756    pub last_error: Option<String>,
757}
758
759/// Shared helper: compute (state_str, elapsed_ms) for a component.
760/// Deduplicates the elapsed-while-building logic and state-to-string mapping
761/// between bm25_summary and semantic_summary.
762fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
763    let elapsed_ms = if matches!(c.state, State::Building) {
764        c.started_ms.map(|start| now_ms().saturating_sub(start))
765    } else {
766        c.duration_ms
767    };
768    let state = match c.state {
769        State::Idle => "idle",
770        State::Building => "building",
771        State::Ready => "ready",
772        State::Failed => "failed",
773    };
774    (state, elapsed_ms)
775}
776
777pub fn semantic_summary(project_root: &str) -> SemanticSummary {
778    let entry = entry_for(project_root);
779    let s = entry
780        .lock()
781        .unwrap_or_else(std::sync::PoisonError::into_inner);
782    let c = &s.semantic;
783    let (state, elapsed_ms) = component_elapsed_and_state(c);
784    SemanticSummary {
785        state,
786        elapsed_ms,
787        note: c.note.clone(),
788        last_error: c.last_error.clone(),
789    }
790}
791
792pub fn bm25_summary(project_root: &str) -> Bm25Summary {
793    let entry = entry_for(project_root);
794    let s = entry
795        .lock()
796        .unwrap_or_else(std::sync::PoisonError::into_inner);
797    let c = &s.bm25;
798    let (state, elapsed_ms) = component_elapsed_and_state(c);
799    Bm25Summary {
800        state,
801        elapsed_ms,
802        note: c.note.clone(),
803        last_error: c.last_error.clone(),
804    }
805}
806
807pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
808    // Resident cache: avoids re-materializing the index from the property graph
809    // (SQLite query) on every graph-touching query. Returns an in-memory clone.
810    crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
811}
812
813pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
814    BM25Index::load(Path::new(project_root))
815}
816
817/// Typed progress snapshot for one index component.
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
819pub struct SlotProgress {
820    pub building: bool,
821    pub done: u64,
822    pub total: u64,
823}
824
825impl SlotProgress {
826    pub fn is_determinate(&self) -> bool {
827        self.building && self.total > 0
828    }
829}
830
831/// Root-scoped view of index build progress for CLI wait loops.
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct ProgressView {
834    pub worker_running: bool,
835    /// Graph phase of the current worker run has finished (ok or err).
836    pub graph_run_done: bool,
837    /// BM25 phase of the current worker run has finished (ok or err).
838    pub bm25_run_done: bool,
839    pub graph: SlotProgress,
840    pub bm25: SlotProgress,
841    pub semantic: SlotProgress,
842}
843
844impl ProgressView {
845    /// Graph + BM25 pipeline still in flight for this root.
846    ///
847    /// True from worker claim through BM25 completion — covers the claim→start
848    /// race and the graph→BM25 handoff without waiting for search pre-warm.
849    pub fn graph_bm25_active(&self) -> bool {
850        self.worker_running && !(self.graph_run_done && self.bm25_run_done)
851    }
852
853    pub fn semantic_active(&self) -> bool {
854        self.semantic.building
855    }
856}
857
858/// Snapshot progress for a project root (typed; no JSON scrape).
859pub fn progress_view(project_root: &str) -> ProgressView {
860    use crate::core::index_progress::{self, IndexComponent};
861    let (worker_running, graph_run_done, bm25_run_done, graph_b, bm25_b, sem_b) = {
862        let state = entry_for(project_root);
863        let s = state
864            .lock()
865            .unwrap_or_else(std::sync::PoisonError::into_inner);
866        (
867            s.worker_running,
868            s.graph_run_done,
869            s.bm25_run_done,
870            matches!(s.graph.state, State::Building),
871            matches!(s.bm25.state, State::Building),
872            matches!(s.semantic.state, State::Building),
873        )
874    };
875    let g = index_progress::get(project_root, IndexComponent::Graph);
876    let b = index_progress::get(project_root, IndexComponent::Bm25);
877    let sem = index_progress::get(project_root, IndexComponent::Semantic);
878    ProgressView {
879        worker_running,
880        graph_run_done,
881        bm25_run_done,
882        graph: SlotProgress {
883            building: graph_b,
884            done: g.done,
885            total: g.total,
886        },
887        bm25: SlotProgress {
888            building: bm25_b,
889            done: b.done,
890            total: b.total,
891        },
892        semantic: SlotProgress {
893            building: sem_b,
894            done: sem.done,
895            total: sem.total,
896        },
897    }
898}
899
900/// Returns true if any project is currently building its indices.
901pub fn is_building() -> bool {
902    let map = registry()
903        .lock()
904        .unwrap_or_else(std::sync::PoisonError::into_inner);
905    map.values().any(|entry| {
906        let st = entry
907            .lock()
908            .unwrap_or_else(std::sync::PoisonError::into_inner);
909        matches!(st.bm25.state, State::Building)
910            || matches!(st.graph.state, State::Building)
911            || matches!(st.semantic.state, State::Building)
912    })
913}
914
915#[derive(Debug, Serialize)]
916struct ComponentStatus<'a> {
917    state: &'a str,
918    started_ms: Option<u64>,
919    finished_ms: Option<u64>,
920    duration_ms: Option<u64>,
921    last_error: Option<&'a str>,
922    #[serde(skip_serializing_if = "Option::is_none")]
923    note: Option<&'a str>,
924    /// Units completed while `building` (files/chunks). Omitted when idle/ready.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    progress_done: Option<u64>,
927    /// Total units; `Some(0)` while building means indeterminate.
928    #[serde(skip_serializing_if = "Option::is_none")]
929    progress_total: Option<u64>,
930}
931
932fn component_status(
933    c: &Component,
934    progress: crate::core::index_progress::ProgressSnapshot,
935) -> ComponentStatus<'_> {
936    let (progress_done, progress_total) = if matches!(c.state, State::Building) {
937        (Some(progress.done), Some(progress.total))
938    } else {
939        (None, None)
940    };
941    ComponentStatus {
942        state: match c.state {
943            State::Idle => "idle",
944            State::Building => "building",
945            State::Ready => "ready",
946            State::Failed => "failed",
947        },
948        started_ms: c.started_ms,
949        finished_ms: c.finished_ms,
950        duration_ms: c.duration_ms,
951        last_error: c.last_error.as_deref(),
952        note: c.note.as_deref(),
953        progress_done,
954        progress_total,
955    }
956}
957
958#[derive(Debug, Serialize)]
959struct StatusResponse<'a> {
960    project_root: &'a str,
961    graph_index: ComponentStatus<'a>,
962    bm25_index: ComponentStatus<'a>,
963    /// Dense embedding index built after BM25.  "idle" means the ONNX model
964    /// has not been downloaded yet or the embeddings feature was not compiled
965    /// in; "ready" means embeddings are persisted and search will use them.
966    semantic_index: ComponentStatus<'a>,
967    disk: DiskStatusAll,
968    /// Active corpus filter summary (#735). Omitted for the unfiltered
969    /// default, keeping default output byte-identical.
970    #[serde(skip_serializing_if = "Option::is_none")]
971    index_filters: Option<String>,
972}
973
974#[derive(Debug, Serialize, Default)]
975pub struct DiskStatus {
976    pub exists: bool,
977    pub size_bytes: Option<u64>,
978    pub file_count: Option<u64>,
979    pub modified_at: Option<String>,
980    /// Semantic index only: total chunks when the persisted index is an
981    /// interrupted build's checkpoint (`file_count` then counts the embedded
982    /// ones). Absent for a complete index.
983    #[serde(skip_serializing_if = "Option::is_none")]
984    pub partial_of: Option<u64>,
985}
986
987#[derive(Debug, Serialize, Default)]
988pub struct DiskStatusAll {
989    pub graph_index: DiskStatus,
990    pub bm25_index: DiskStatus,
991    pub code_graph: DiskStatus,
992    /// On-disk embedding index (`embeddings.bin`).  Present when dense search
993    /// has been built at least once; absent when the model is not downloaded
994    /// yet or embeddings are disabled by config.
995    pub semantic_index: DiskStatus,
996}
997
998/// On-disk state of the graph index: its catalogued files, timed by
999/// `graph.meta.json`. Reads row counts only — never materializes the index
1000/// (#2179: that checked every catalogued file through the serving gate and
1001/// left `doctor` running for minutes on a large project).
1002pub fn disk_status_for_graph(project_root: &str) -> DiskStatus {
1003    // #696 C4: the property graph is the sole store. The logical graph-index
1004    // view (file count) is timed by `graph.meta.json`, which the mirror stamps
1005    // on every build; `disk_status_for_code_graph` reports the raw SQLite store
1006    // (nodes, graph.db size) as a distinct facet. The meta file's own size is
1007    // not the index size, so none is reported here (#1914).
1008    let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
1009        return DiskStatus::default();
1010    };
1011    let meta_file = dir.join("graph.meta.json");
1012    if !meta_file.exists() {
1013        return DiskStatus::default();
1014    }
1015    let meta = std::fs::metadata(&meta_file).ok();
1016    // An empty catalog is an unbuilt index, as `ProjectIndex::load` reads it.
1017    let file_count = crate::core::property_graph::stored_counts(project_root)
1018        .map(|counts| counts.files)
1019        .filter(|files| *files > 0);
1020    DiskStatus {
1021        exists: true,
1022        size_bytes: None,
1023        file_count,
1024        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1025        partial_of: None,
1026    }
1027}
1028
1029/// On-disk state of the BM25 index: file size and the chunk count from its
1030/// header, without loading it.
1031pub fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
1032    let root = Path::new(project_root);
1033    let path = BM25Index::index_file_path(root);
1034    if !path.exists() {
1035        return DiskStatus::default();
1036    }
1037    let meta = std::fs::metadata(&path).ok();
1038    DiskStatus {
1039        exists: true,
1040        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1041        file_count: BM25Index::persisted_chunk_count(root),
1042        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1043        partial_of: None,
1044    }
1045}
1046
1047fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
1048    let dir = crate::core::property_graph::graph_dir(project_root);
1049    let db_path = dir.join("graph.db");
1050    if !db_path.exists() {
1051        return DiskStatus::default();
1052    }
1053    let meta = std::fs::metadata(&db_path).ok();
1054    // A status read opens the store without migrating it and gives up on a
1055    // graph another process holds busy (#2179).
1056    let node_count =
1057        crate::core::property_graph::stored_counts(project_root).map(|counts| counts.nodes);
1058    DiskStatus {
1059        exists: true,
1060        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1061        file_count: node_count,
1062        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1063        partial_of: None,
1064    }
1065}
1066
1067fn format_time(t: SystemTime) -> String {
1068    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1069    let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
1070    dt.map_or_else(
1071        || format!("{secs}"),
1072        |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
1073    )
1074}
1075
1076pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
1077    let root = Path::new(project_root);
1078    let dir = crate::core::index_namespace::vectors_dir(root);
1079    let bin_path = dir.join("embeddings.bin");
1080    if !bin_path.exists() {
1081        return DiskStatus::default();
1082    }
1083    let meta = std::fs::metadata(&bin_path).ok();
1084    let partial = crate::core::embedding_index::partial_progress(root);
1085    DiskStatus {
1086        exists: true,
1087        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
1088        file_count: partial.map(|(done, _)| done),
1089        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
1090        partial_of: partial.map(|(_, total)| total),
1091    }
1092}
1093
1094pub fn disk_status(project_root: &str) -> DiskStatusAll {
1095    DiskStatusAll {
1096        graph_index: disk_status_for_graph(project_root),
1097        bm25_index: disk_status_for_bm25(project_root),
1098        code_graph: disk_status_for_code_graph(project_root),
1099        semantic_index: disk_status_for_semantic(project_root),
1100    }
1101}
1102
1103pub fn status_json(project_root: &str) -> String {
1104    // Compute disk status first — may do SQLite I/O and must NOT hold L2
1105    // (per-project Mutex) while doing so, or the background index worker
1106    // cannot call finish_ok / set worker_running = false (#deadlock).
1107    let disk = disk_status(project_root);
1108    let state = entry_for(project_root);
1109    let s = state
1110        .lock()
1111        .unwrap_or_else(std::sync::PoisonError::into_inner);
1112    let graph_p = crate::core::index_progress::get(
1113        project_root,
1114        crate::core::index_progress::IndexComponent::Graph,
1115    );
1116    let bm25_p = crate::core::index_progress::get(
1117        project_root,
1118        crate::core::index_progress::IndexComponent::Bm25,
1119    );
1120    let semantic_p = crate::core::index_progress::get(
1121        project_root,
1122        crate::core::index_progress::IndexComponent::Semantic,
1123    );
1124    let res = StatusResponse {
1125        project_root,
1126        graph_index: component_status(&s.graph, graph_p),
1127        bm25_index: component_status(&s.bm25, bm25_p),
1128        semantic_index: component_status(&s.semantic, semantic_p),
1129        disk,
1130        index_filters: crate::core::index_filter::IndexFileFilter::effective().summary(),
1131    };
1132    serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138
1139    #[test]
1140    fn progress_view_graph_bm25_active_logic() {
1141        let idle = SlotProgress::default();
1142        let mut v = ProgressView {
1143            worker_running: true,
1144            graph_run_done: false,
1145            bm25_run_done: false,
1146            graph: idle,
1147            bm25: idle,
1148            semantic: idle,
1149        };
1150        assert!(v.graph_bm25_active(), "claimed, phases not done");
1151        v.graph_run_done = true;
1152        assert!(v.graph_bm25_active(), "graph done, bm25 pending");
1153        v.bm25_run_done = true;
1154        assert!(
1155            !v.graph_bm25_active(),
1156            "both phases done — search pre-warm must not keep wait open"
1157        );
1158        v.worker_running = false;
1159        assert!(!v.graph_bm25_active());
1160        // Idle project (never claimed this process)
1161        v.graph_run_done = true;
1162        v.bm25_run_done = true;
1163        assert!(!v.graph_bm25_active());
1164    }
1165
1166    /// `index build` waits for the slot without a deadline; a worker that
1167    /// panics must still release it, or the CLI would wait forever.
1168    #[test]
1169    fn worker_slot_is_released_when_the_worker_panics() {
1170        let root = "/tmp/leanctx-worker-slot-panic-test";
1171        assert!(try_claim_worker(root));
1172        assert!(progress_view(root).graph_bm25_active());
1173        let result = std::panic::catch_unwind(|| {
1174            let _slot = WorkerSlot(root.to_string());
1175            panic!("simulated failure outside the phase catch_unwind");
1176        });
1177        assert!(result.is_err());
1178        let view = progress_view(root);
1179        assert!(!view.worker_running, "slot released by the guard");
1180        assert!(!view.graph_bm25_active(), "the CLI wait loop ends");
1181        assert!(try_claim_worker(root), "a later build can claim it again");
1182        drop(WorkerSlot(root.to_string()));
1183    }
1184
1185    #[test]
1186    fn status_json_is_valid_json() {
1187        let s = status_json("/tmp");
1188        let _: serde_json::Value = serde_json::from_str(&s).unwrap();
1189    }
1190
1191    #[test]
1192    fn background_build_phases_are_serialized_with_reclamation() {
1193        let events = std::cell::RefCell::new(Vec::new());
1194        run_memory_bounded_phases(
1195            || events.borrow_mut().push("graph"),
1196            || events.borrow_mut().push("bm25"),
1197            || events.borrow_mut().push("search"),
1198            || events.borrow_mut().push("reclaim"),
1199        );
1200        assert_eq!(
1201            events.into_inner(),
1202            ["graph", "reclaim", "bm25", "reclaim", "search"]
1203        );
1204    }
1205
1206    #[test]
1207    fn warm_need_classifies_tools() {
1208        // Lightweight tools must never trigger a project scan (#152).
1209        for light in [
1210            "ctx_read",
1211            "ctx_shell",
1212            "ctx_tree",
1213            "ctx_knowledge",
1214            "unknown_tool",
1215        ] {
1216            assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
1217        }
1218        // ctx_search only needs the cheap trigram index.
1219        assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
1220        for heavy in [
1221            "ctx_graph",
1222            "ctx_callgraph",
1223            "ctx_artifacts",
1224            "ctx_provider",
1225            "ctx_compose",
1226        ] {
1227            assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
1228        }
1229    }
1230
1231    #[test]
1232    fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
1233        assert!(!ensure_warm_for_tool("", "ctx_graph"));
1234        let tmp = tempfile::tempdir().unwrap();
1235        let root = tmp.path().to_string_lossy().to_string();
1236        assert!(!ensure_warm_for_tool(&root, "ctx_read"));
1237        assert!(!ensure_warm_for_tool(&root, "ctx_search"));
1238    }
1239
1240    #[test]
1241    fn ensure_warm_heavy_is_once_per_root() {
1242        // The first heavy pre-warm signals `true` (so the caller warms extra
1243        // roots once); every subsequent call is a no-op `false`, preventing a
1244        // rebuild-on-every-dispatch storm.
1245        let tmp = tempfile::tempdir().unwrap();
1246        let root = tmp.path().to_string_lossy().to_string();
1247        assert!(
1248            ensure_warm_for_tool(&root, "ctx_callgraph"),
1249            "first heavy warm must signal true"
1250        );
1251        assert!(
1252            !ensure_warm_for_tool(&root, "ctx_callgraph"),
1253            "second heavy warm must be deduped to false"
1254        );
1255        assert!(
1256            !ensure_warm_for_tool(&root, "ctx_compose"),
1257            "any later heavy tool on the same root is also deduped"
1258        );
1259    }
1260
1261    #[test]
1262    fn build_note_persisted_reports_size() {
1263        let note = bm25_build_note(
1264            42,
1265            &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
1266                compressed_bytes: 3 * 1024 * 1024,
1267            }),
1268        );
1269        assert!(
1270            note.contains("42 chunks"),
1271            "note should report chunk count: {note}"
1272        );
1273        assert!(
1274            note.contains("persisted"),
1275            "note should report persistence: {note}"
1276        );
1277    }
1278
1279    #[test]
1280    fn build_note_too_large_carries_remedy() {
1281        let note = bm25_build_note(
1282            1000,
1283            &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
1284                compressed_bytes: 600 * 1024 * 1024,
1285                limit_bytes: 512 * 1024 * 1024,
1286            }),
1287        );
1288        assert!(
1289            note.contains("NOT persisted"),
1290            "must flag non-persistence: {note}"
1291        );
1292        assert!(
1293            note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
1294            "too-large note must carry an actionable remedy: {note}"
1295        );
1296    }
1297
1298    #[test]
1299    fn build_note_persist_error_is_reported() {
1300        let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
1301        assert!(note.contains("persisting failed"), "note: {note}");
1302        assert!(
1303            note.contains("disk full"),
1304            "note should include the io error: {note}"
1305        );
1306    }
1307
1308    #[test]
1309    fn bm25_summary_unknown_project_is_idle() {
1310        let tmp = tempfile::tempdir().unwrap();
1311        let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
1312        assert_eq!(summary.state, "idle");
1313        assert!(summary.note.is_none());
1314        assert!(summary.last_error.is_none());
1315    }
1316
1317    #[test]
1318    fn extra_roots_skips_subdirs_of_primary() {
1319        let tmp = tempfile::tempdir().unwrap();
1320        let primary = tmp.path().join("primary");
1321        std::fs::create_dir_all(&primary).unwrap();
1322        let sub = primary.join("subdir");
1323        std::fs::create_dir_all(&sub).unwrap();
1324        let external = tmp.path().join("external");
1325        std::fs::create_dir_all(&external).unwrap();
1326
1327        let primary_str = primary.to_string_lossy().to_string();
1328        let extra = vec![
1329            sub.to_string_lossy().to_string(),
1330            external.to_string_lossy().to_string(),
1331        ];
1332
1333        // Should not panic; subdirs are skipped, external is attempted
1334        ensure_extra_roots_background(&primary_str, &extra);
1335    }
1336
1337    #[test]
1338    fn extra_roots_caps_at_max() {
1339        let tmp = tempfile::tempdir().unwrap();
1340        let primary = tmp.path().join("primary");
1341        std::fs::create_dir_all(&primary).unwrap();
1342
1343        let mut extra = Vec::new();
1344        for i in 0..20 {
1345            let d = tmp.path().join(format!("ext-{i}"));
1346            std::fs::create_dir_all(&d).unwrap();
1347            extra.push(d.to_string_lossy().to_string());
1348        }
1349
1350        let primary_str = primary.to_string_lossy().to_string();
1351        // Should not spawn more than MAX_EXTRA_ROOT_BUILDS threads
1352        ensure_extra_roots_background(&primary_str, &extra);
1353    }
1354
1355    #[test]
1356    fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
1357        let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
1358        let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
1359        assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
1360        assert_ne!(a, b, "lock name must be per-repo");
1361        // Stable for the same repo across calls.
1362        assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
1363        // Must NOT collide with the graph lock for the same repo, or the two
1364        // builds would serialize against each other unnecessarily.
1365        let graph = format!(
1366            "graph-idx-{}",
1367            &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
1368        );
1369        assert_ne!(a, graph, "bm25 and graph locks must be independent");
1370    }
1371}