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    /// Dense embedding index (semantic search). Built after BM25 as Phase 3.
55    /// Tracked separately so the orchestrator does not block on a missing ONNX
56    /// model — the status lets users see why semantic stays cold (#249).
57    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
72// Lock ordering (see rust/LOCK_ORDERING.md):
73//   L1 = REGISTRY outer Mutex  (the HashMap guard)
74//   L2 = per-project Arc<Mutex<ProjectBuild>>  (inner guard)
75//
76// Invariant: L1 must NEVER be held while locking L2.
77// `entry_for()` enforces this by cloning the Arc and dropping L1 before
78// the caller acquires L2.
79static 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
101/// Per-repo lock name for serializing the BM25 build across processes, mirroring
102/// the `graph-idx-<hash>` lock (see LOCK_ORDERING.md). The distinct `bm25-` vs
103/// `graph-` prefix keeps the graph and BM25 builds from serializing against each
104/// other while still preventing N processes from rebuilding either in parallel.
105fn 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/// The index warmth a tool benefits from. Drives lazy, demand-driven warming
137/// (issue #152) so the server no longer scans the whole project eagerly on every
138/// `initialize` — a session that only uses `ctx_read`/`ctx_shell`/`ctx_tree`
139/// pays zero indexing cost.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum WarmNeed {
142    /// No prebuilt index needed.
143    None,
144    /// Only the resident line-search (trigram) index — cheap, used by `ctx_search`.
145    Search,
146    /// Full project indices (graph + BM25; this also warms the search index).
147    Heavy,
148}
149
150/// Classify a tool by the index warmth it benefits from. Unknown tools default
151/// to [`WarmNeed::None`]; a heavy tool mis-classified as `None` still works — it
152/// just builds its index synchronously on first use instead of being pre-warmed.
153#[must_use]
154pub fn warm_need_for_tool(tool: &str) -> WarmNeed {
155    match tool {
156        "ctx_search" => WarmNeed::Search,
157        // Tools that build/consume the graph, call-graph, BM25 or artifact index.
158        "ctx_graph"
159        | "ctx_callgraph"
160        | "ctx_routes"
161        | "ctx_repomap"
162        | "ctx_impact"
163        | "ctx_artifacts"
164        | "ctx_semantic_search"
165        | "ctx_provider"
166        | "ctx_compose"
167        | "ctx_review" => WarmNeed::Heavy,
168        _ => WarmNeed::None,
169    }
170}
171
172/// Lazily warm the indices a tool needs, deduped per root. Never blocks (all
173/// work is spawned in the background) and is safe to call on every dispatch.
174///
175/// Returns `true` only when this call is the *first* heavy pre-warm for `root`
176/// in this process — the caller can use that signal to warm secondary roots once
177/// without re-reading session state on every dispatch.
178pub fn ensure_warm_for_tool(project_root: &str, tool: &str) -> bool {
179    if project_root.is_empty() {
180        return false;
181    }
182    match warm_need_for_tool(tool) {
183        WarmNeed::None => false,
184        WarmNeed::Search => {
185            // The search index has its own TTL + background-rebuild dedup, so it
186            // is safe (and cheap) to nudge on every `ctx_search`.
187            crate::core::search_index::ensure_background(project_root, true, false);
188            false
189        }
190        WarmNeed::Heavy => {
191            let entry = entry_for(project_root);
192            let first_warm = {
193                let mut s = entry
194                    .lock()
195                    .unwrap_or_else(std::sync::PoisonError::into_inner);
196                if s.warm_triggered {
197                    false
198                } else {
199                    s.warm_triggered = true;
200                    true
201                }
202            };
203            if first_warm {
204                ensure_all_background(project_root);
205            }
206            first_warm
207        }
208    }
209}
210
211/// Stack size for background index workers. Large enough that deep ASTs and
212/// graph traversals cannot overflow it (the #378 SIGABRT class). The AST walks
213/// are iterative now too, so this is defense-in-depth.
214const INDEXER_STACK_BYTES: usize = 16 * 1024 * 1024;
215
216/// Fire-and-forget: ask a running daemon to own the index build for `root`
217/// (#460, shared-indexer-daemon / thin clients).
218///
219/// The daemon is the single long-lived, machine-wide indexer. Once it holds the
220/// per-repo `graph-idx`/`bm25-idx` build locks, every session load-shares its
221/// on-disk result instead of each running a full scan during a cold boot wave —
222/// turning N simultaneous index passes into ~one. Strictly additive and
223/// best-effort: it runs on its own thread (no ambient runtime to nest into), is
224/// skipped when we *are* the daemon or when no daemon is reachable, and never
225/// blocks the caller. The local build started right after remains the fallback,
226/// so indexing always works with no daemon present.
227fn nudge_daemon_index(project_root: &str) {
228    // The daemon must never delegate the build to itself.
229    if crate::daemon::is_foreground_daemon() {
230        return;
231    }
232    let root = project_root.to_string();
233    let _ = std::thread::Builder::new()
234        .name("leanctx-index-nudge".to_string())
235        .spawn(move || {
236            if !crate::daemon::is_daemon_running() {
237                return;
238            }
239            let Ok(rt) = tokio::runtime::Runtime::new() else {
240                return;
241            };
242            let body = serde_json::json!({ "root": root }).to_string();
243            rt.block_on(async {
244                let _ = crate::daemon_client::try_daemon_request("POST", "/v1/index/ensure", &body)
245                    .await;
246            });
247        });
248}
249
250pub fn ensure_all_background(project_root: &str) {
251    let state = entry_for(project_root);
252    let should_spawn = {
253        let mut s = state
254            .lock()
255            .unwrap_or_else(std::sync::PoisonError::into_inner);
256        if s.worker_running {
257            false
258        } else {
259            s.worker_running = true;
260            true
261        }
262    };
263
264    if !should_spawn {
265        return;
266    }
267
268    // #460: hand the build to the daemon (the single machine-wide indexer) when
269    // one is running and we aren't it. Deduped naturally — we only reach here
270    // when this process is actually about to start a build. Purely additive: the
271    // local build below still runs and load-shares via the per-repo locks.
272    nudge_daemon_index(project_root);
273
274    let root = project_root.to_string();
275    let indexer = move || {
276        // Pre-warm the resident line-search index in parallel (own thread,
277        // deduped internally) so the first ctx_search hits the fast path.
278        crate::core::search_index::ensure_background(&root, true, false);
279
280        // ---- Parallel Phase: Graph + BM25 ----
281        let graph_state = entry_for(&root);
282        let graph_root = root.clone();
283        let graph_handle = std::thread::Builder::new()
284            .name("leanctx-graph".to_string())
285            .stack_size(INDEXER_STACK_BYTES)
286            .spawn(move || {
287                {
288                    let mut s = graph_state
289                        .lock()
290                        .unwrap_or_else(std::sync::PoisonError::into_inner);
291                    start_component(&mut s.graph);
292                }
293                let graph_result = std::panic::catch_unwind(|| {
294                    let (idx, _cache) = graph_index::scan_with_content_cache(&graph_root);
295                    // #696 C4: the property graph is the sole store. `save()` mirrors
296                    // the freshly scanned index into PG (stamping `graph.meta.json`) in
297                    // this same reliable worker, so PG inherits the scan's build reliability.
298                    if let Err(e) = idx.save() {
299                        tracing::warn!("[index_orchestrator: graph save failed: {e}]");
300                    }
301                });
302                if let Ok(()) = graph_result {
303                    let mut s = graph_state
304                        .lock()
305                        .unwrap_or_else(std::sync::PoisonError::into_inner);
306                    finish_ok(&mut s.graph);
307                } else {
308                    let mut s = graph_state
309                        .lock()
310                        .unwrap_or_else(std::sync::PoisonError::into_inner);
311                    finish_err(&mut s.graph, "graph index build panicked".to_string());
312                }
313            })
314            .expect("spawning graph index thread");
315
316        let bm25_state = entry_for(&root);
317        let bm25_root = root.clone();
318        let bm25_handle = std::thread::Builder::new()
319            .name("leanctx-bm25".to_string())
320            .stack_size(INDEXER_STACK_BYTES)
321            .spawn(move || {
322                {
323                    let mut s = bm25_state
324                        .lock()
325                        .unwrap_or_else(std::sync::PoisonError::into_inner);
326                    start_component(&mut s.bm25);
327                }
328                let bm = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
329                    let root_pb = Path::new(&bm25_root);
330                    // Cross-instance build coordination: serialize the (expensive) BM25
331                    // build per repo, mirroring the `graph-idx` lock in graph_index.
332                    let lock_name = bm25_index_lock_name(root_pb);
333                    let _lock = crate::core::startup_guard::try_acquire_lock(
334                        &lock_name,
335                        std::time::Duration::from_millis(800),
336                        std::time::Duration::from_mins(3),
337                    );
338                    if _lock.is_none() {
339                        tracing::info!(
340                            "[bm25: another process is building {bm25_root} — loading the shared index]"
341                        );
342                        let idx = BM25Index::load(root_pb).unwrap_or_default();
343                        return (idx.doc_count, None);
344                    }
345                    let idx = BM25Index::load_or_build(root_pb);
346                    let outcome = idx.save(root_pb);
347                    (idx.doc_count, Some(outcome))
348                }));
349                if let Ok((doc_count, save_res)) = bm {
350                     let mut s = bm25_state
351                         .lock()
352                         .unwrap_or_else(std::sync::PoisonError::into_inner);
353                     finish_ok(&mut s.bm25);
354                     s.bm25.note = Some(match save_res {
355                         Some(outcome) => bm25_build_note(doc_count, &outcome),
356                         None => format!(
357                             "loaded shared BM25 index ({doc_count} chunks) — build in progress in another process"
358                         ),
359                     });
360                 } else {
361                     let mut s = bm25_state
362                         .lock()
363                         .unwrap_or_else(std::sync::PoisonError::into_inner);
364                     finish_err(&mut s.bm25, "bm25 build panicked".to_string());
365                 }
366            })
367            .expect("spawning BM25 index thread");
368
369        if let Err(e) = graph_handle.join() {
370            tracing::error!("[index_orchestrator: graph thread panicked: {e:?}]");
371        }
372        if let Err(e) = bm25_handle.join() {
373            tracing::error!("[index_orchestrator: BM25 thread panicked: {e:?}]");
374        }
375
376        let final_state = entry_for(&root);
377        let mut s = final_state
378            .lock()
379            .unwrap_or_else(std::sync::PoisonError::into_inner);
380        s.worker_running = false;
381    };
382
383    // Indexing parses large ASTs and traverses graphs; give the worker a
384    // generous stack as defense-in-depth against deep-recursion overflow (the
385    // #378 SIGABRT class) and a name so it is identifiable in crash dumps.
386    let spawned = std::thread::Builder::new()
387        .name("leanctx-index".to_string())
388        .stack_size(INDEXER_STACK_BYTES)
389        .spawn(indexer);
390    if spawned.is_err() {
391        // The OS refused a new thread (rare). Clear the in-flight flag so a
392        // later trigger retries instead of assuming a build runs forever.
393        let mut s = state
394            .lock()
395            .unwrap_or_else(std::sync::PoisonError::into_inner);
396        s.worker_running = false;
397    }
398}
399
400/// Build only the semantic (dense embedding) index from the existing BM25 index.
401/// The BM25 index must already exist on disk — this function loads it and runs
402/// `embedding_index::build_or_update`. Updates the in-memory semantic component
403/// state on completion.
404pub fn build_semantic(project_root: &str) {
405    let state = entry_for(project_root);
406    let root = Path::new(project_root);
407
408    {
409        let mut s = state
410            .lock()
411            .unwrap_or_else(std::sync::PoisonError::into_inner);
412        start_component(&mut s.semantic);
413    }
414
415    let bm25_idx = try_load_bm25_index(project_root);
416    match bm25_idx.as_ref() {
417        Some(idx) if idx.doc_count > 0 => {
418            let outcome = crate::core::embedding_index::build_or_update(root, idx);
419            let mut s = state
420                .lock()
421                .unwrap_or_else(std::sync::PoisonError::into_inner);
422            match outcome {
423                crate::core::embedding_index::EmbeddingBuildOutcome::Ready => {
424                    finish_ok(&mut s.semantic);
425                }
426                crate::core::embedding_index::EmbeddingBuildOutcome::Skipped => {
427                    finish_ok(&mut s.semantic);
428                    s.semantic.note = Some(
429                        "embeddings disabled by feature flag or config (search.dense_enabled / memory_profile)"
430                            .to_string(),
431                    );
432                }
433                crate::core::embedding_index::EmbeddingBuildOutcome::ModelNotAvailable(
434                    ref reason,
435                ) => {
436                    s.semantic.state = State::Idle;
437                    s.semantic.note = Some(format!("embedding model not available: {reason}"));
438                }
439                crate::core::embedding_index::EmbeddingBuildOutcome::Failed => {
440                    finish_err(
441                        &mut s.semantic,
442                        "embedding build failed (see logs)".to_string(),
443                    );
444                }
445            }
446        }
447        _ => {
448            let mut s = state
449                .lock()
450                .unwrap_or_else(std::sync::PoisonError::into_inner);
451            s.semantic.state = State::Idle;
452            s.semantic.note =
453                Some("BM25 index is empty or unavailable — nothing to embed".to_string());
454        }
455    }
456}
457
458/// Ensure background indexing for all extra roots (in addition to the primary).
459/// Each extra root that is not a subdirectory of `primary_root` gets its own
460/// graph + BM25 index. Capped at `MAX_EXTRA_ROOT_BUILDS` to prevent runaway.
461const MAX_EXTRA_ROOT_BUILDS: usize = 8;
462
463pub fn ensure_extra_roots_background(primary_root: &str, extra_roots: &[String]) {
464    let primary = Path::new(primary_root);
465    let mut built = 0;
466    for root in extra_roots {
467        if built >= MAX_EXTRA_ROOT_BUILDS {
468            break;
469        }
470        let rp = Path::new(root);
471        if !rp.is_dir() {
472            continue;
473        }
474        // Skip if extra_root is inside primary (already indexed by the primary scan)
475        if rp.starts_with(primary) {
476            continue;
477        }
478        // Skip if primary is inside this extra_root (avoid double-indexing the parent)
479        if primary.starts_with(rp) {
480            continue;
481        }
482        ensure_all_background(root);
483        built += 1;
484    }
485}
486
487/// Build a human-readable outcome note for a finished BM25 build, including the
488/// indexed chunk count and whether the index was persisted to disk. A
489/// "too large" refusal carries the exact remedy so the operator (or agent) is
490/// never left guessing why search/ranking stays cold (issue #249).
491fn bm25_build_note(
492    doc_count: usize,
493    save: &std::io::Result<crate::core::bm25_index::SaveOutcome>,
494) -> String {
495    use crate::core::bm25_index::SaveOutcome;
496    match save {
497        Ok(SaveOutcome::Persisted { compressed_bytes }) => format!(
498            "indexed {doc_count} chunks, {:.1} MB persisted",
499            *compressed_bytes as f64 / 1_048_576.0
500        ),
501        Ok(SaveOutcome::SkippedTooLarge {
502            compressed_bytes,
503            limit_bytes,
504        }) => format!(
505            "indexed {doc_count} chunks but NOT persisted to disk: compressed {:.1} MB exceeds the {:.0} MB cap. \
506             Raise it via LEAN_CTX_BM25_MAX_CACHE_MB (or bm25_max_cache_mb in config) or add extra_ignore_patterns, \
507             then run `lean-ctx reindex`. Until then the index is rebuilt from scratch on every cold start.",
508            *compressed_bytes as f64 / 1_048_576.0,
509            *limit_bytes as f64 / 1_048_576.0
510        ),
511        Err(e) => format!("indexed {doc_count} chunks but persisting failed: {e}"),
512    }
513}
514
515/// Lightweight, allocation-frugal snapshot of the BM25 component for the
516/// in-call composer/search messaging. Avoids the heavier [`disk_status`] walk.
517#[derive(Debug, Clone)]
518pub struct Bm25Summary {
519    pub state: &'static str,
520    /// While building: elapsed so far. Otherwise: last build duration.
521    pub elapsed_ms: Option<u64>,
522    pub note: Option<String>,
523    pub last_error: Option<String>,
524}
525
526/// Lightweight snapshot of the semantic (dense embedding) component.
527#[derive(Debug, Clone)]
528pub struct SemanticSummary {
529    pub state: &'static str,
530    pub elapsed_ms: Option<u64>,
531    pub note: Option<String>,
532    pub last_error: Option<String>,
533}
534
535/// Shared helper: compute (state_str, elapsed_ms) for a component.
536/// Deduplicates the elapsed-while-building logic and state-to-string mapping
537/// between bm25_summary and semantic_summary.
538fn component_elapsed_and_state(c: &Component) -> (&'static str, Option<u64>) {
539    let elapsed_ms = if matches!(c.state, State::Building) {
540        c.started_ms.map(|start| now_ms().saturating_sub(start))
541    } else {
542        c.duration_ms
543    };
544    let state = match c.state {
545        State::Idle => "idle",
546        State::Building => "building",
547        State::Ready => "ready",
548        State::Failed => "failed",
549    };
550    (state, elapsed_ms)
551}
552
553pub fn semantic_summary(project_root: &str) -> SemanticSummary {
554    let entry = entry_for(project_root);
555    let s = entry
556        .lock()
557        .unwrap_or_else(std::sync::PoisonError::into_inner);
558    let c = &s.semantic;
559    let (state, elapsed_ms) = component_elapsed_and_state(c);
560    SemanticSummary {
561        state,
562        elapsed_ms,
563        note: c.note.clone(),
564        last_error: c.last_error.clone(),
565    }
566}
567
568pub fn bm25_summary(project_root: &str) -> Bm25Summary {
569    let entry = entry_for(project_root);
570    let s = entry
571        .lock()
572        .unwrap_or_else(std::sync::PoisonError::into_inner);
573    let c = &s.bm25;
574    let (state, elapsed_ms) = component_elapsed_and_state(c);
575    Bm25Summary {
576        state,
577        elapsed_ms,
578        note: c.note.clone(),
579        last_error: c.last_error.clone(),
580    }
581}
582
583pub fn try_load_graph_index(project_root: &str) -> Option<ProjectIndex> {
584    // Resident cache: avoids re-materializing the index from the property graph
585    // (SQLite query) on every graph-touching query. Returns an in-memory clone.
586    crate::core::graph_cache::get_cached(project_root).map(|arc| (*arc).clone())
587}
588
589pub fn try_load_bm25_index(project_root: &str) -> Option<BM25Index> {
590    BM25Index::load(Path::new(project_root))
591}
592
593/// Returns true if any project is currently building its indices.
594pub fn is_building() -> bool {
595    let map = registry()
596        .lock()
597        .unwrap_or_else(std::sync::PoisonError::into_inner);
598    map.values().any(|entry| {
599        let st = entry
600            .lock()
601            .unwrap_or_else(std::sync::PoisonError::into_inner);
602        matches!(st.bm25.state, State::Building)
603            || matches!(st.graph.state, State::Building)
604            || matches!(st.semantic.state, State::Building)
605    })
606}
607
608#[derive(Debug, Serialize)]
609struct ComponentStatus<'a> {
610    state: &'a str,
611    started_ms: Option<u64>,
612    finished_ms: Option<u64>,
613    duration_ms: Option<u64>,
614    last_error: Option<&'a str>,
615    #[serde(skip_serializing_if = "Option::is_none")]
616    note: Option<&'a str>,
617}
618
619fn component_status(c: &Component) -> ComponentStatus<'_> {
620    ComponentStatus {
621        state: match c.state {
622            State::Idle => "idle",
623            State::Building => "building",
624            State::Ready => "ready",
625            State::Failed => "failed",
626        },
627        started_ms: c.started_ms,
628        finished_ms: c.finished_ms,
629        duration_ms: c.duration_ms,
630        last_error: c.last_error.as_deref(),
631        note: c.note.as_deref(),
632    }
633}
634
635#[derive(Debug, Serialize)]
636struct StatusResponse<'a> {
637    project_root: &'a str,
638    graph_index: ComponentStatus<'a>,
639    bm25_index: ComponentStatus<'a>,
640    /// Dense embedding index built after BM25.  "idle" means the ONNX model
641    /// has not been downloaded yet or the embeddings feature was not compiled
642    /// in; "ready" means embeddings are persisted and search will use them.
643    semantic_index: ComponentStatus<'a>,
644    disk: DiskStatusAll,
645}
646
647#[derive(Debug, Serialize, Default)]
648pub struct DiskStatus {
649    pub exists: bool,
650    pub size_bytes: Option<u64>,
651    pub file_count: Option<u64>,
652    pub modified_at: Option<String>,
653}
654
655#[derive(Debug, Serialize, Default)]
656pub struct DiskStatusAll {
657    pub graph_index: DiskStatus,
658    pub bm25_index: DiskStatus,
659    pub code_graph: DiskStatus,
660    /// On-disk embedding index (`embeddings.bin`).  Present when dense search
661    /// has been built at least once; absent when the model is not downloaded
662    /// yet or embeddings are disabled by config.
663    pub semantic_index: DiskStatus,
664}
665
666fn disk_status_for_graph(project_root: &str) -> DiskStatus {
667    // #696 C4: the property graph is the sole store. The logical graph-index
668    // view (file count) is sized/timed by `graph.meta.json`, which the mirror
669    // stamps on every build; `disk_status_for_code_graph` reports the raw
670    // SQLite store (nodes, graph.db) as a distinct facet.
671    let Some(dir) = graph_index::ProjectIndex::index_dir(project_root) else {
672        return DiskStatus::default();
673    };
674    let meta_file = dir.join("graph.meta.json");
675    if !meta_file.exists() {
676        return DiskStatus::default();
677    }
678    let meta = std::fs::metadata(&meta_file).ok();
679    let file_count =
680        graph_index::ProjectIndex::load(project_root).map(|idx| idx.files.len() as u64);
681    DiskStatus {
682        exists: true,
683        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
684        file_count,
685        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
686    }
687}
688
689fn disk_status_for_bm25(project_root: &str) -> DiskStatus {
690    let root = Path::new(project_root);
691    let path = BM25Index::index_file_path(root);
692    if !path.exists() {
693        return DiskStatus::default();
694    }
695    let meta = std::fs::metadata(&path).ok();
696    DiskStatus {
697        exists: true,
698        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
699        file_count: None,
700        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
701    }
702}
703
704fn disk_status_for_code_graph(project_root: &str) -> DiskStatus {
705    let dir = crate::core::property_graph::graph_dir(project_root);
706    let db_path = dir.join("graph.db");
707    if !db_path.exists() {
708        return DiskStatus::default();
709    }
710    let meta = std::fs::metadata(&db_path).ok();
711    let node_count = crate::core::property_graph::CodeGraph::open(project_root)
712        .ok()
713        .and_then(|g| {
714            g.connection()
715                .query_row("SELECT count(*) FROM nodes", [], |r| r.get::<_, i64>(0))
716                .ok()
717                .map(|c| c as u64)
718        });
719    DiskStatus {
720        exists: true,
721        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
722        file_count: node_count,
723        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
724    }
725}
726
727fn format_time(t: SystemTime) -> String {
728    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
729    let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
730    dt.map_or_else(
731        || format!("{secs}"),
732        |d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
733    )
734}
735
736pub fn disk_status_for_semantic(project_root: &str) -> DiskStatus {
737    let root = Path::new(project_root);
738    let dir = crate::core::index_namespace::vectors_dir(root);
739    let bin_path = dir.join("embeddings.bin");
740    if !bin_path.exists() {
741        return DiskStatus::default();
742    }
743    let meta = std::fs::metadata(&bin_path).ok();
744    DiskStatus {
745        exists: true,
746        size_bytes: meta.as_ref().map(std::fs::Metadata::len),
747        file_count: None,
748        modified_at: meta.and_then(|m| m.modified().ok()).map(format_time),
749    }
750}
751
752pub fn disk_status(project_root: &str) -> DiskStatusAll {
753    DiskStatusAll {
754        graph_index: disk_status_for_graph(project_root),
755        bm25_index: disk_status_for_bm25(project_root),
756        code_graph: disk_status_for_code_graph(project_root),
757        semantic_index: disk_status_for_semantic(project_root),
758    }
759}
760
761pub fn status_json(project_root: &str) -> String {
762    // Compute disk status first — may do SQLite I/O and must NOT hold L2
763    // (per-project Mutex) while doing so, or the background index worker
764    // cannot call finish_ok / set worker_running = false (#deadlock).
765    let disk = disk_status(project_root);
766    let state = entry_for(project_root);
767    let s = state
768        .lock()
769        .unwrap_or_else(std::sync::PoisonError::into_inner);
770    let res = StatusResponse {
771        project_root,
772        graph_index: component_status(&s.graph),
773        bm25_index: component_status(&s.bm25),
774        semantic_index: component_status(&s.semantic),
775        disk,
776    };
777    serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string())
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn status_json_is_valid_json() {
786        let s = status_json("/tmp");
787        let _: serde_json::Value = serde_json::from_str(&s).unwrap();
788    }
789
790    #[test]
791    fn warm_need_classifies_tools() {
792        // Lightweight tools must never trigger a project scan (#152).
793        for light in [
794            "ctx_read",
795            "ctx_shell",
796            "ctx_tree",
797            "ctx_knowledge",
798            "unknown_tool",
799        ] {
800            assert_eq!(warm_need_for_tool(light), WarmNeed::None, "{light}");
801        }
802        // ctx_search only needs the cheap trigram index.
803        assert_eq!(warm_need_for_tool("ctx_search"), WarmNeed::Search);
804        for heavy in [
805            "ctx_graph",
806            "ctx_callgraph",
807            "ctx_routes",
808            "ctx_repomap",
809            "ctx_impact",
810            "ctx_artifacts",
811            "ctx_semantic_search",
812            "ctx_provider",
813            "ctx_compose",
814            "ctx_review",
815        ] {
816            assert_eq!(warm_need_for_tool(heavy), WarmNeed::Heavy, "{heavy}");
817        }
818    }
819
820    #[test]
821    fn ensure_warm_lightweight_and_search_never_signal_first_warm() {
822        assert!(!ensure_warm_for_tool("", "ctx_graph"));
823        let tmp = tempfile::tempdir().unwrap();
824        let root = tmp.path().to_string_lossy().to_string();
825        assert!(!ensure_warm_for_tool(&root, "ctx_read"));
826        assert!(!ensure_warm_for_tool(&root, "ctx_search"));
827    }
828
829    #[test]
830    fn ensure_warm_heavy_is_once_per_root() {
831        // The first heavy pre-warm signals `true` (so the caller warms extra
832        // roots once); every subsequent call is a no-op `false`, preventing a
833        // rebuild-on-every-dispatch storm.
834        let tmp = tempfile::tempdir().unwrap();
835        let root = tmp.path().to_string_lossy().to_string();
836        assert!(
837            ensure_warm_for_tool(&root, "ctx_callgraph"),
838            "first heavy warm must signal true"
839        );
840        assert!(
841            !ensure_warm_for_tool(&root, "ctx_callgraph"),
842            "second heavy warm must be deduped to false"
843        );
844        assert!(
845            !ensure_warm_for_tool(&root, "ctx_semantic_search"),
846            "any later heavy tool on the same root is also deduped"
847        );
848    }
849
850    #[test]
851    fn build_note_persisted_reports_size() {
852        let note = bm25_build_note(
853            42,
854            &Ok(crate::core::bm25_index::SaveOutcome::Persisted {
855                compressed_bytes: 3 * 1024 * 1024,
856            }),
857        );
858        assert!(
859            note.contains("42 chunks"),
860            "note should report chunk count: {note}"
861        );
862        assert!(
863            note.contains("persisted"),
864            "note should report persistence: {note}"
865        );
866    }
867
868    #[test]
869    fn build_note_too_large_carries_remedy() {
870        let note = bm25_build_note(
871            1000,
872            &Ok(crate::core::bm25_index::SaveOutcome::SkippedTooLarge {
873                compressed_bytes: 600 * 1024 * 1024,
874                limit_bytes: 512 * 1024 * 1024,
875            }),
876        );
877        assert!(
878            note.contains("NOT persisted"),
879            "must flag non-persistence: {note}"
880        );
881        assert!(
882            note.contains("LEAN_CTX_BM25_MAX_CACHE_MB") && note.contains("reindex"),
883            "too-large note must carry an actionable remedy: {note}"
884        );
885    }
886
887    #[test]
888    fn build_note_persist_error_is_reported() {
889        let note = bm25_build_note(7, &Err(std::io::Error::other("disk full")));
890        assert!(note.contains("persisting failed"), "note: {note}");
891        assert!(
892            note.contains("disk full"),
893            "note should include the io error: {note}"
894        );
895    }
896
897    #[test]
898    fn bm25_summary_unknown_project_is_idle() {
899        let tmp = tempfile::tempdir().unwrap();
900        let summary = bm25_summary(tmp.path().to_string_lossy().as_ref());
901        assert_eq!(summary.state, "idle");
902        assert!(summary.note.is_none());
903        assert!(summary.last_error.is_none());
904    }
905
906    #[test]
907    fn extra_roots_skips_subdirs_of_primary() {
908        let tmp = tempfile::tempdir().unwrap();
909        let primary = tmp.path().join("primary");
910        std::fs::create_dir_all(&primary).unwrap();
911        let sub = primary.join("subdir");
912        std::fs::create_dir_all(&sub).unwrap();
913        let external = tmp.path().join("external");
914        std::fs::create_dir_all(&external).unwrap();
915
916        let primary_str = primary.to_string_lossy().to_string();
917        let extra = vec![
918            sub.to_string_lossy().to_string(),
919            external.to_string_lossy().to_string(),
920        ];
921
922        // Should not panic; subdirs are skipped, external is attempted
923        ensure_extra_roots_background(&primary_str, &extra);
924    }
925
926    #[test]
927    fn extra_roots_caps_at_max() {
928        let tmp = tempfile::tempdir().unwrap();
929        let primary = tmp.path().join("primary");
930        std::fs::create_dir_all(&primary).unwrap();
931
932        let mut extra = Vec::new();
933        for i in 0..20 {
934            let d = tmp.path().join(format!("ext-{i}"));
935            std::fs::create_dir_all(&d).unwrap();
936            extra.push(d.to_string_lossy().to_string());
937        }
938
939        let primary_str = primary.to_string_lossy().to_string();
940        // Should not spawn more than MAX_EXTRA_ROOT_BUILDS threads
941        ensure_extra_roots_background(&primary_str, &extra);
942    }
943
944    #[test]
945    fn bm25_index_lock_name_is_per_repo_and_distinct_from_graph() {
946        let a = bm25_index_lock_name(Path::new("/tmp/repo-a"));
947        let b = bm25_index_lock_name(Path::new("/tmp/repo-b"));
948        assert!(a.starts_with("bm25-idx-"), "unexpected lock name: {a}");
949        assert_ne!(a, b, "lock name must be per-repo");
950        // Stable for the same repo across calls.
951        assert_eq!(a, bm25_index_lock_name(Path::new("/tmp/repo-a")));
952        // Must NOT collide with the graph lock for the same repo, or the two
953        // builds would serialize against each other unnecessarily.
954        let graph = format!(
955            "graph-idx-{}",
956            &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8]
957        );
958        assert_ne!(a, graph, "bm25 and graph locks must be independent");
959    }
960}