Skip to main content

lean_ctx/core/
search_index.rs

1//! Resident line-search index for `ctx_search` (Phase 1 of the efficiency epic).
2//!
3//! Historically `ctx_search` walked the filesystem, read every file, and ran a
4//! regex on every line on *every* call — `O(files × lines)`. That is the
5//! 40–200 ms latency floor this module eliminates.
6//!
7//! This module keeps a RAM-resident trigram index (`trigram → file ids`) so the
8//! common case (an identifier / literal query) collapses to: intersect a few
9//! posting lists in memory → read & regex-verify only the handful of candidate
10//! files. The index never decides matches itself; it only *narrows the file
11//! set*, then `ctx_search` verifies candidates with the exact same regex loop,
12//! so the returned `file:line` hits are byte-identical to the walk path.
13//!
14//! Design notes:
15//! - The index is built with the *same* walk config and file filters as
16//!   `ctx_search` (see `tools::ctx_search`) so the searchable universe
17//!   is identical — that is what guarantees recall parity.
18//! - Only `[A-Za-z0-9_]` trigrams are indexed. Lookups only ever use trigrams
19//!   from pure-identifier queries, so this is both sufficient and memory-bounded.
20//! - Narrowing is applied *only* for pure `[A-Za-z0-9_]` literal queries (the
21//!   dominant agent case). Any query containing a regex metacharacter falls
22//!   back to scanning the cached file list (still skips the directory walk).
23//! - Freshness is a function of *corpus state*, not the clock: the build records
24//!   a cheap, order-independent signature over the eligible files' `(path,
25//!   mtime, size)`, and every [`get_fresh`] re-derives it via a stat-only walk
26//!   that shares the build's exact filter path. The resident index is served
27//!   only when the signature matches live disk — so an edit, *even through a
28//!   tool lean-ctx never observes* (native editors, `git checkout`), is
29//!   reflected on the very next search instead of lingering for a TTL window.
30//!   A push-based fs-watcher (zero per-lookup cost) is a possible future
31//!   optimization on top of this correctness gate.
32
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Mutex, OnceLock};
36use std::time::{Duration, Instant};
37
38use glob::Pattern;
39use ignore::WalkBuilder;
40
41use crate::core::search_walk::{MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file};
42
43/// Upper bound on indexed files; larger trees fall back to the walk path.
44const MAX_FILES: usize = 200_000;
45
46/// Posting-entry budget (`file_id` occurrences across all trigrams). Up to this
47/// many entries we keep exact inverted posting lists (fastest, sparse lookups).
48/// Beyond it we switch to the per-file Bloom tier instead of giving up — see
49/// [`Narrowing`]. ~4 bytes each → ~48 MB before the switch.
50const MAX_POSTING_ENTRIES: usize = 12_000_000;
51
52/// Hard ceiling on total trigram entries collected during a build. Past this we
53/// abandon indexing (walk fallback) to avoid pathological memory use even with
54/// the compact Bloom tier.
55const MAX_TOTAL_ENTRIES: usize = 48_000_000;
56
57/// Bloom tuning: bits per distinct trigram and number of hash probes. ~12 bits
58/// with k=7 keeps the false-positive rate well under 1% — and a false positive
59/// only costs one extra regex-verified file read (never a missed match).
60const BLOOM_BITS_PER_ITEM: usize = 12;
61const BLOOM_K: usize = 7;
62/// Per-file Bloom size clamp (in bits): 64 bits min, 1 Mi bits (128 KiB) max.
63const BLOOM_MIN_BITS: usize = 64;
64const BLOOM_MAX_BITS: usize = 1 << 20;
65
66/// A trigram is indexable only if all three bytes are `[A-Za-z0-9_]`.
67fn is_word_byte(b: u8) -> bool {
68    b.is_ascii_alphanumeric() || b == b'_'
69}
70
71fn pack(b0: u8, b1: u8, b2: u8) -> u32 {
72    (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2)
73}
74
75/// How candidate files are narrowed for a literal query. Two tiers, chosen by
76/// corpus size, both providing a *superset* of true matches (zero false
77/// negatives) which `ctx_search` then regex-verifies:
78/// - `Postings`: exact inverted lists `trigram → sorted file ids`. Fast, sparse
79///   lookups; used while total entries fit [`MAX_POSTING_ENTRIES`].
80/// - `Blooms`: one compact per-file Bloom filter of the file's trigrams. ~3×
81///   smaller than postings, so monorepos that would otherwise blow the posting
82///   budget still get index-narrowing instead of a full directory walk.
83enum Narrowing {
84    Postings(HashMap<u32, Vec<u32>>),
85    Blooms(Vec<FileBloom>),
86}
87
88/// A per-file Bloom filter over the file's word-trigrams. No false negatives:
89/// if any probed bit for a trigram is unset, the file provably lacks it.
90struct FileBloom {
91    /// Bit storage; the filter width `m = bits.len() * 64` is a power of two.
92    bits: Vec<u64>,
93}
94
95/// 64-bit avalanche mix (splitmix64 finalizer) — spreads a packed trigram into
96/// a well-distributed hash for double-probing.
97#[inline]
98fn mix64(mut x: u64) -> u64 {
99    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
100    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
101    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
102    x ^ (x >> 31)
103}
104
105impl FileBloom {
106    fn with_capacity(distinct_trigrams: usize) -> Self {
107        let target = distinct_trigrams
108            .saturating_mul(BLOOM_BITS_PER_ITEM)
109            .next_power_of_two()
110            .clamp(BLOOM_MIN_BITS, BLOOM_MAX_BITS);
111        FileBloom {
112            bits: vec![0u64; target / 64],
113        }
114    }
115
116    #[inline]
117    fn m_bits(&self) -> usize {
118        self.bits.len() * 64
119    }
120
121    /// Double hashing: `p_i = h1 + i·h2 (mod m)` with `m` a power of two.
122    #[inline]
123    fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
124        let m = self.m_bits();
125        let mask = m - 1; // m is a power of two
126        let h = mix64(u64::from(trigram));
127        let h1 = (h & 0xFFFF_FFFF) as usize;
128        let h2 = ((h >> 32) as usize) | 1; // odd step → full-period probing
129        (0..BLOOM_K).map(move |i| h1.wrapping_add(i.wrapping_mul(h2)) & mask)
130    }
131
132    fn insert(&mut self, trigram: u32) {
133        for p in self.probes(trigram).collect::<Vec<_>>() {
134            self.bits[p / 64] |= 1u64 << (p % 64);
135        }
136    }
137
138    fn maybe_contains(&self, trigram: u32) -> bool {
139        self.probes(trigram)
140            .all(|p| self.bits[p / 64] & (1u64 << (p % 64)) != 0)
141    }
142}
143
144/// RAM-resident trigram index over one project root.
145pub struct SearchIndex {
146    files: Vec<PathBuf>,
147    /// Candidate-narrowing structure (exact postings or compact per-file Bloom).
148    narrowing: Narrowing,
149    respect_gitignore: bool,
150    allow_secret_paths: bool,
151    /// Signature of the on-disk corpus this index was built from — the freshness
152    /// truth checked on every [`get_fresh`] (see [`corpus_signature`]).
153    signature: u64,
154    /// Admission-policy epoch the indexed text was admitted under (G5): an
155    /// index from an older policy is never served.
156    policy_epoch: u64,
157}
158
159impl SearchIndex {
160    /// Build the index by walking `root` with the exact same config and filters
161    /// as `ctx_search`, so the searchable file universe is identical.
162    pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
163        let mut files: Vec<PathBuf> = Vec::new();
164        // Per-file sorted, deduped trigrams. Same memory as the posting lists
165        // would be, but grouped by file so we can materialise *either* tier
166        // afterwards without a second pass over the corpus.
167        let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
168        let mut total_entries: usize = 0;
169        let mut scratch: HashSet<u32> = HashSet::new();
170        // Corpus-signature accumulators. The running sum is order-independent
171        // (commutative `wrapping_add`) and the file count is folded in at the end
172        // so a change that happens to cancel under addition is still detected.
173        // Folded over the *eligible-by-stat* universe — before the read below —
174        // so the stat-only re-walk in [`corpus_signature`] reproduces it exactly,
175        // including files that turn out to be binary (counted, never indexed).
176        let mut sig_sum: u64 = 0;
177        let mut file_count: usize = 0;
178        let mut aborted = false;
179        // One admission policy for the whole build (G5).
180        let admission = crate::core::context_admission::stores::StoreAdmission::current();
181
182        walk_index_corpus(
183            root,
184            respect_gitignore,
185            allow_secret_paths,
186            None,
187            |path, state| {
188                sig_sum = sig_sum.wrapping_add(file_sig(path, state));
189                file_count += 1;
190
191                // Read the corpus exactly once (issue #148): reuse a fresh cached
192                // copy if a prior `ctx_search`/build already read this file, else
193                // read it now and publish it so the upcoming `ctx_search` verify
194                // pass is an in-memory hit instead of a second disk read. Mirrors
195                // ctx_search: a binary file is never searchable, so it is skipped
196                // for trigrams (but already folded into the signature above).
197                let content: std::sync::Arc<str> = if let Some(cached) =
198                    state.and_then(|s| crate::core::content_cache::get(path, s))
199                {
200                    cached
201                } else {
202                    // Only admitted text enters the shared cache; withheld and
203                    // restricted sources are never searchable (G5, E3).
204                    let Some(text) = admission.read(path) else {
205                        return true;
206                    };
207                    let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
208                    if let Some(s) = state {
209                        crate::core::content_cache::insert(
210                            path,
211                            s,
212                            std::sync::Arc::clone(&arc),
213                            &admission,
214                        );
215                    }
216                    arc
217                };
218
219                if files.len() >= MAX_FILES {
220                    aborted = true; // too large even for the Bloom tier — use the walk
221                    return false;
222                }
223
224                scratch.clear();
225                let bytes = content.as_bytes();
226                if bytes.len() >= 3 {
227                    for w in bytes.windows(3) {
228                        if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
229                            scratch.insert(pack(w[0], w[1], w[2]));
230                        }
231                    }
232                }
233                total_entries += scratch.len();
234                if total_entries > MAX_TOTAL_ENTRIES {
235                    aborted = true; // memory guard — fall back to walk
236                    return false;
237                }
238                let mut tris: Vec<u32> = scratch.iter().copied().collect();
239                tris.sort_unstable();
240                files.push(path.to_path_buf());
241                per_file_trigrams.push(tris);
242                true
243            },
244        )?;
245
246        if aborted {
247            return None;
248        }
249
250        let narrowing = build_narrowing(&per_file_trigrams, total_entries);
251
252        Some(Self {
253            files,
254            narrowing,
255            respect_gitignore,
256            allow_secret_paths,
257            signature: finalize_sig(sig_sum, file_count),
258            policy_epoch: admission.epoch(),
259        })
260    }
261
262    fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
263        self.respect_gitignore == respect_gitignore
264            && self.allow_secret_paths == allow_secret_paths
265            && self.policy_epoch == crate::core::context_admission::stores::current_epoch()
266    }
267
268    /// Candidate files for `pattern`, filtered by the `include` glob (matched
269    /// against each file's path relative to `root`). `None` means "no safe
270    /// narrowing possible" — the caller should scan the full file list.
271    ///
272    /// Narrowing is applied only for pure `[A-Za-z0-9_]` literals of length ≥ 3.
273    /// For such a literal every match contains it on a single line, hence the
274    /// file contains all of its consecutive trigrams: intersecting their
275    /// posting lists yields a *superset* of matching files (zero false
276    /// negatives), which the caller then regex-verifies.
277    pub fn candidate_paths(
278        &self,
279        pattern: &str,
280        includes: &[Pattern],
281        root: &Path,
282    ) -> CandidateSet {
283        if let Some(ids) = self.literal_candidates(pattern) {
284            let paths = ids
285                .into_iter()
286                .map(|id| self.files[id as usize].clone())
287                .filter(|p| glob_matches(p, includes, root))
288                .collect();
289            CandidateSet::Narrowed(paths)
290        } else {
291            let paths = self
292                .files
293                .iter()
294                .filter(|p| glob_matches(p, includes, root))
295                .cloned()
296                .collect();
297            CandidateSet::FullList(paths)
298        }
299    }
300
301    /// How many indexed files the `include`/`exclude` filters admit — the
302    /// search's scope before trigram narrowing. #1994: a narrowed miss scans
303    /// no file at all, so without this a genuine miss and a filter that
304    /// matched nothing both reported zero files.
305    pub fn scope_count(&self, includes: &[Pattern], excludes: &[Pattern], root: &Path) -> usize {
306        self.files
307            .iter()
308            .filter(|p| glob_matches(p, includes, root))
309            .filter(|p| excludes.is_empty() || !glob_matches(p, excludes, root))
310            .count()
311    }
312
313    /// Returns candidate file ids for a pure-literal query, or `None` if the
314    /// query is not a trigram-narrowable pure `[A-Za-z0-9_]` literal. Both tiers
315    /// return a *superset* of true matches (zero false negatives).
316    fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
317        let bytes = pattern.as_bytes();
318        if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
319            return None;
320        }
321        // Distinct trigrams of the literal.
322        let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
323        tris.sort_unstable();
324        tris.dedup();
325
326        match &self.narrowing {
327            Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
328            Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
329        }
330    }
331
332    /// Exact-tier: intersect the posting lists of every required trigram
333    /// (smallest first for a cheap intersection).
334    fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
335        let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
336        for &tri in tris {
337            match trigrams.get(&tri) {
338                // A required trigram is absent → provably no match anywhere.
339                None => return Vec::new(),
340                Some(list) => lists.push(list),
341            }
342        }
343        lists.sort_by_key(|l| l.len());
344
345        let mut acc: Vec<u32> = lists[0].clone();
346        for list in &lists[1..] {
347            acc = intersect_sorted(&acc, list);
348            if acc.is_empty() {
349                break;
350            }
351        }
352        acc
353    }
354
355    /// Bloom-tier: a file is a candidate iff its Bloom filter may contain every
356    /// required trigram. No false negatives (an unset probe bit ⇒ the trigram is
357    /// provably absent), so the result is still a superset of true matches.
358    fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
359        let mut out = Vec::new();
360        for (fid, bloom) in blooms.iter().enumerate() {
361            if tris.iter().all(|&t| bloom.maybe_contains(t)) {
362                out.push(fid as u32);
363            }
364        }
365        out
366    }
367}
368
369/// Materialise the appropriate narrowing tier for a freshly walked corpus.
370fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
371    if total_entries <= MAX_POSTING_ENTRIES {
372        let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
373        for (fid, tris) in per_file.iter().enumerate() {
374            for &t in tris {
375                // file ids are appended in ascending order ⇒ lists stay sorted.
376                trigrams.entry(t).or_default().push(fid as u32);
377            }
378        }
379        Narrowing::Postings(trigrams)
380    } else {
381        let blooms = per_file
382            .iter()
383            .map(|tris| {
384                let mut b = FileBloom::with_capacity(tris.len());
385                for &t in tris {
386                    b.insert(t);
387                }
388                b
389            })
390            .collect();
391        Narrowing::Blooms(blooms)
392    }
393}
394
395/// Result of [`SearchIndex::candidate_paths`].
396pub enum CandidateSet {
397    /// Trigram-narrowed candidate files (a superset of real matches).
398    Narrowed(Vec<PathBuf>),
399    /// No safe narrowing — the full cached file list (still skips the walk).
400    FullList(Vec<PathBuf>),
401}
402
403impl CandidateSet {
404    pub fn into_paths(self) -> Vec<PathBuf> {
405        match self {
406            CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
407        }
408    }
409}
410
411/// True when `path` matches *any* of the `includes` globs (relative to `root`),
412/// or when there is no filter (`includes` empty).
413fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
414    if includes.is_empty() {
415        return true;
416    }
417    let rel = path.strip_prefix(root).unwrap_or(path);
418    let rel_str = rel.to_string_lossy();
419    includes.iter().any(|p| p.matches(&rel_str))
420}
421
422/// Secret-like files under `root` that match `includes`, for boundary-skip
423/// reporting on the trigram-index fast path (#1481). The include filter runs
424/// before the secret-like check so the note reflects only files the query
425/// would have searched.
426pub fn boundary_skipped_files(
427    root: &str,
428    respect_gitignore: bool,
429    includes: &[Pattern],
430) -> Vec<(String, &'static str)> {
431    let root_path = Path::new(root);
432    if !root_path.exists() {
433        return Vec::new();
434    }
435    if !crate::core::graph_index::is_safe_scan_root_public(root) {
436        return Vec::new();
437    }
438
439    let walker = WalkBuilder::new(root_path)
440        .hidden(false)
441        .max_depth(Some(crate::core::search_walk::MAX_WALK_DEPTH))
442        .git_ignore(respect_gitignore)
443        .git_global(respect_gitignore)
444        .git_exclude(respect_gitignore)
445        .require_git(false)
446        .filter_entry(move |e| {
447            if respect_gitignore {
448                crate::core::walk_filter::keep_entry(e)
449            } else {
450                crate::core::cloud_files::keep_entry(e)
451            }
452        })
453        .build();
454
455    let mut skipped = Vec::new();
456    for entry in walker.filter_map(std::result::Result::ok) {
457        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
458            continue;
459        }
460        if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
461            continue;
462        }
463        let path = entry.path();
464        if is_binary_ext(path) || is_generated_file(path) {
465            continue;
466        }
467        if !includes.is_empty() && !glob_matches(path, includes, root_path) {
468            continue;
469        }
470        if let Some(reason) = crate::core::io_boundary::is_secret_like(path) {
471            let rel = path.strip_prefix(root_path).unwrap_or(path);
472            skipped.push((rel.to_string_lossy().into_owned(), reason));
473        }
474    }
475    skipped.sort_unstable();
476    skipped
477}
478
479/// Intersection of two ascending, deduped `u32` slices.
480fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
481    let mut out = Vec::new();
482    let (mut i, mut j) = (0, 0);
483    while i < a.len() && j < b.len() {
484        match a[i].cmp(&b[j]) {
485            std::cmp::Ordering::Less => i += 1,
486            std::cmp::Ordering::Greater => j += 1,
487            std::cmp::Ordering::Equal => {
488                out.push(a[i]);
489                i += 1;
490                j += 1;
491            }
492        }
493    }
494    out
495}
496
497// ---------------------------------------------------------------------------
498// Corpus freshness: the build and its freshness check share one traversal so
499// the signature is computed over an identical file universe (parity is what
500// makes the equality check trustworthy).
501// ---------------------------------------------------------------------------
502
503/// Walk `root` with the *exact* `ctx_search` file universe — same gitignore
504/// semantics, depth limit, and binary/generated/secret/size/regular-file guards
505/// — invoking `visit(path, state)` for every eligible regular file with its
506/// `(mtime, size)` identity (`None` only when the platform cannot report mtime).
507/// `visit` returns `false` to stop early. Returns `None` for a missing or unsafe
508/// scan root (HOME, fs root, …), the same guard that makes the caller fall back
509/// to a direct walk. This traversal reads nothing: it is the shared spine of
510/// both [`SearchIndex::build`] and [`corpus_signature`].
511fn walk_index_corpus<F>(
512    root: &str,
513    respect_gitignore: bool,
514    allow_secret_paths: bool,
515    includes: Option<&[Pattern]>,
516    mut visit: F,
517) -> Option<()>
518where
519    F: FnMut(&Path, Option<crate::core::content_cache::FileState>) -> bool,
520{
521    let root_path = Path::new(root);
522    if !root_path.exists() {
523        return None;
524    }
525    // Never auto-index a broad/unsafe root (HOME, filesystem root, a dir with
526    // dozens of unrelated subtrees). Mirrors the graph/BM25 guard and stops a
527    // walk of the whole home directory — which on Windows would hydrate OneDrive
528    // placeholders (#363).
529    if !crate::core::graph_index::is_safe_scan_root_public(root) {
530        return None;
531    }
532
533    let walker = WalkBuilder::new(root_path)
534        // #1792: same corpus rule as the BM25 walk above — see
535        // `walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK`.
536        .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
537        .max_depth(Some(MAX_WALK_DEPTH))
538        .git_ignore(respect_gitignore)
539        .git_global(respect_gitignore)
540        .git_exclude(respect_gitignore)
541        .require_git(false)
542        .filter_entry(crate::core::walk_filter::keep_entry)
543        .build();
544
545    for entry in walker.filter_map(std::result::Result::ok) {
546        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
547            continue;
548        }
549        if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
550            continue;
551        }
552        let path = entry.path();
553        if is_binary_ext(path) || is_generated_file(path) {
554            continue;
555        }
556        if let Some(includes) = includes {
557            if !includes.is_empty() && !glob_matches(path, includes, root_path) {
558                continue;
559            }
560        }
561        if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
562            continue;
563        }
564        // `metadata` (stat) never opens the file, so it cannot block on a
565        // FIFO/socket/device node (#336); those are filtered out here.
566        let state = match std::fs::metadata(path) {
567            Ok(meta) if !meta.file_type().is_file() => continue,
568            Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
569            Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
570            Err(_) => continue,
571        };
572        if !visit(path, state) {
573            break;
574        }
575    }
576    Some(())
577}
578
579/// Stable per-file contribution to the corpus signature: folds the path with its
580/// `(mtime, size)` identity. Order-independent (callers combine with
581/// `wrapping_add`), so traversal order never affects the result. A file with no
582/// resolvable mtime contributes its path only — add/rename/delete are still
583/// detected, while an in-place edit on such an exotic filesystem is the same
584/// blind spot the `(mtime, size)` content cache already documents.
585fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
586    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
587    for &b in path.as_os_str().as_encoded_bytes() {
588        h ^= u64::from(b);
589        h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime
590    }
591    if let Some(st) = state {
592        h ^= mix64(st.mtime_ms).rotate_left(1);
593        h ^= mix64(st.size_bytes).rotate_left(33);
594    }
595    mix64(h)
596}
597
598/// Fold the order-independent per-file sum together with the file count into the
599/// final signature, so adding and removing files whose hashes cancel under
600/// addition still changes the result.
601fn finalize_sig(sum: u64, count: usize) -> u64 {
602    sum ^ mix64(count as u64).rotate_left(32)
603}
604
605/// Cheap, stat-only signature of the *current* on-disk corpus for `root`.
606/// Compared against `SearchIndex::signature` in [`get_fresh`] to detect any
607/// change — content edits (including via tools lean-ctx never observes),
608/// additions, renames and deletions — so a stale candidate set can never
609/// silently drop a real match. `None` mirrors a non-indexable root (the caller
610/// then walks directly).
611pub fn corpus_signature(
612    root: &str,
613    respect_gitignore: bool,
614    allow_secret_paths: bool,
615) -> Option<u64> {
616    let mut sum: u64 = 0;
617    let mut count: usize = 0;
618    walk_index_corpus(
619        root,
620        respect_gitignore,
621        allow_secret_paths,
622        None,
623        |path, state| {
624            sum = sum.wrapping_add(file_sig(path, state));
625            count += 1;
626            true
627        },
628    )?;
629    Some(finalize_sig(sum, count))
630}
631
632// ---------------------------------------------------------------------------
633// Resident cache (one index per project root) with background (re)build.
634// ---------------------------------------------------------------------------
635
636struct CacheEntry {
637    index: Option<Arc<SearchIndex>>,
638    building: bool,
639    /// When this root's resident index was last confirmed current against disk.
640    /// Gates the optional coalesce window in [`get_fresh`]; `None` forces a fresh
641    /// verification on the next lookup.
642    last_verified: Option<Instant>,
643}
644
645impl CacheEntry {
646    fn empty() -> Self {
647        Self {
648            index: None,
649            building: false,
650            last_verified: None,
651        }
652    }
653}
654
655static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
656
657fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
658    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
659}
660
661/// Max project roots with a resident index. The daemon builds one for every root
662/// its sessions touch (worktrees, branches, sibling repos) and kept each of them
663/// for its whole lifetime (#2075). LRU-evict beyond this, as `graph_cache` does;
664/// an evicted root walks once and rebuilds on its next search.
665const MAX_RESIDENT_ROOTS: usize = 8;
666
667/// Keep at most [`MAX_RESIDENT_ROOTS`] resident indexes, dropping the least
668/// recently verified first. `keep` (the root just installed) is never dropped.
669fn enforce_root_cap(map: &mut HashMap<String, CacheEntry>, keep: &str) {
670    while map.values().filter(|e| e.index.is_some()).count() > MAX_RESIDENT_ROOTS {
671        let Some(lru) = map
672            .iter()
673            .filter(|(root, e)| e.index.is_some() && root.as_str() != keep)
674            .min_by_key(|(_, e)| e.last_verified)
675            .map(|(root, _)| root.clone())
676        else {
677            break;
678        };
679        // An in-flight rebuild keeps its entry so its `building` flag survives.
680        match map.get_mut(&lru) {
681            Some(entry) if entry.building => {
682                entry.index = None;
683                entry.last_verified = None;
684            }
685            _ => {
686                map.remove(&lru);
687            }
688        }
689    }
690}
691
692/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
693/// everywhere (debugging / A-B measurement / opt-out).
694fn index_disabled() -> bool {
695    std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
696        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
697}
698
699/// Optional coalescing window for the freshness check. Default `0` (disabled):
700/// every [`get_fresh`] re-verifies the corpus signature against disk, so a fresh
701/// edit is never missed. On very large indexed trees, set
702/// `LEAN_CTX_SEARCH_INDEX_COALESCE_MS` to trade a bounded staleness window for
703/// fewer stat-walks under bursty search load.
704fn coalesce_window() -> Option<Duration> {
705    let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
706        .ok()
707        .and_then(|v| v.trim().parse::<u64>().ok())
708        .unwrap_or(0);
709    (ms > 0).then(|| Duration::from_millis(ms))
710}
711
712/// Returns a fresh resident index for `root` if one is available for the given
713/// config, otherwise spawns a background (re)build and returns `None` so the
714/// caller uses the walk fallback for this call.
715pub fn get_fresh(
716    root: &str,
717    respect_gitignore: bool,
718    allow_secret_paths: bool,
719) -> Option<Arc<SearchIndex>> {
720    // Privileged "ignore gitignore" scans are rare and bypass the index.
721    if !respect_gitignore || index_disabled() {
722        return None;
723    }
724
725    // Phase 1 (locked, O(1)): grab the resident index for this config, if any,
726    // together with when it was last verified against disk.
727    let (candidate, last_verified) = {
728        let map = cache()
729            .lock()
730            .unwrap_or_else(std::sync::PoisonError::into_inner);
731        match map.get(root) {
732            Some(entry) => match &entry.index {
733                Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
734                    (Some(Arc::clone(idx)), entry.last_verified)
735                }
736                _ => (None, None),
737            },
738            None => (None, None),
739        }
740    };
741
742    let Some(idx) = candidate else {
743        // No usable index yet → build in the background, walk this call.
744        request_build(root, respect_gitignore, allow_secret_paths);
745        return None;
746    };
747
748    // Coalesce (opt-in, default off): inside the window trust the recent
749    // verification and skip the stat-walk — keeps bursty search load O(1).
750    if let Some(window) = coalesce_window()
751        && last_verified.is_some_and(|t| t.elapsed() < window)
752    {
753        return Some(idx);
754    }
755
756    // Phase 2 (unlocked): verify the live corpus signature. Deliberately held
757    // *outside* the cache mutex so a multi-millisecond stat-walk never serializes
758    // other roots or concurrent searches.
759    match corpus_signature(root, respect_gitignore, allow_secret_paths) {
760        Some(sig) if sig == idx.signature => {
761            mark_verified(root);
762            Some(idx)
763        }
764        _ => {
765            // Corpus changed (or root no longer indexable): walk accurately now
766            // and rebuild the index in the background for the next call.
767            request_build(root, respect_gitignore, allow_secret_paths);
768            None
769        }
770    }
771}
772
773/// Drop every resident trigram index (#685 eviction hook). In-flight builds
774/// keep their `building` flag (the entry is reset, not the build thread), so
775/// a running build still installs its result; subsequent searches fall back
776/// to the walk path until the next `ensure_background` rebuilds. Correctness
777/// is unaffected — the resident index is purely an accelerator. Returns whether
778/// any index was dropped.
779pub fn clear_resident() -> bool {
780    let mut map = cache()
781        .lock()
782        .unwrap_or_else(std::sync::PoisonError::into_inner);
783    let mut dropped = false;
784    for entry in map.values_mut() {
785        dropped |= entry.index.take().is_some();
786        entry.last_verified = None;
787    }
788    dropped
789}
790
791/// Record that `root`'s resident index was just confirmed current, extending its
792/// coalesce window.
793fn mark_verified(root: &str) {
794    let mut map = cache()
795        .lock()
796        .unwrap_or_else(std::sync::PoisonError::into_inner);
797    if let Some(entry) = map.get_mut(root) {
798        entry.last_verified = Some(Instant::now());
799    }
800}
801
802/// Spawn a background (re)build for `root` unless one is already in flight.
803fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
804    let needs_build = {
805        let mut map = cache()
806            .lock()
807            .unwrap_or_else(std::sync::PoisonError::into_inner);
808        let entry = map
809            .entry(root.to_string())
810            .or_insert_with(CacheEntry::empty);
811        if entry.building {
812            false
813        } else {
814            entry.building = true;
815            true
816        }
817    };
818    if needs_build {
819        spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
820    }
821}
822
823/// Ensure a resident index for `root` is built (or building) in the background.
824/// Safe to call repeatedly; deduped via the per-root `building` flag.
825pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
826    if !respect_gitignore || index_disabled() {
827        return;
828    }
829    // Prewarm only when there is no usable index yet — staleness of an existing
830    // index is corrected on demand by `get_fresh`'s signature gate, so there is
831    // nothing to proactively refresh here.
832    let has_index = {
833        let map = cache()
834            .lock()
835            .unwrap_or_else(std::sync::PoisonError::into_inner);
836        map.get(root).is_some_and(|entry| {
837            entry
838                .index
839                .as_ref()
840                .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
841        })
842    };
843    if !has_index {
844        request_build(root, respect_gitignore, allow_secret_paths);
845    }
846}
847
848/// Build the index synchronously and install it in the resident cache.
849/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
850/// guaranteed-warm index. Respects the disable env var.
851pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
852    if !respect_gitignore || index_disabled() {
853        return false;
854    }
855    let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
856        return false;
857    };
858    let mut map = cache()
859        .lock()
860        .unwrap_or_else(std::sync::PoisonError::into_inner);
861    map.insert(
862        root.to_string(),
863        CacheEntry {
864            index: Some(Arc::new(idx)),
865            building: false,
866            last_verified: Some(Instant::now()),
867        },
868    );
869    enforce_root_cap(&mut map, root);
870    true
871}
872
873/// Per-repo lock name serializing the resident search-index build across
874/// processes, mirroring the `graph-idx` / `bm25-idx` locks in
875/// [`crate::core::index_orchestrator`]. Distinct `search-` prefix so the three
876/// indexers never serialize against one another.
877fn search_index_lock_name(root: &str) -> String {
878    format!(
879        "search-idx-{}",
880        &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
881    )
882}
883
884/// Outcome of a guarded background build: did this process do the walk, or did
885/// it yield to another process already building the same root?
886#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887enum BuildOutcome {
888    Built,
889    Deferred,
890}
891
892/// Build the resident index under a cross-process herd guard (#460).
893///
894/// The trigram index is RAM-resident (not shareable on disk), so on lock
895/// contention we *defer* the proactive pre-warm instead of running a second
896/// simultaneous file walk: a boot wave of N sessions on one repo then triggers
897/// ~1 walk at a time, not N. Deferring is safe — `ctx_search` still works via
898/// its walk fallback, and the per-process `building` flag is cleared so the next
899/// `get_fresh` / `ensure_background` nudge retries once the holder releases. The
900/// short 200 ms wait keeps the common single-session path latency-free.
901fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
902    let lock = crate::core::startup_guard::try_acquire_lock(
903        &search_index_lock_name(root),
904        Duration::from_millis(200),
905        Duration::from_mins(3),
906    );
907    if lock.is_none() {
908        // Another process owns the build. Clear the in-flight flag so a later
909        // nudge retries rather than leaving `building` stuck true forever.
910        let mut map = cache()
911            .lock()
912            .unwrap_or_else(std::sync::PoisonError::into_inner);
913        if let Some(entry) = map.get_mut(root) {
914            entry.building = false;
915        }
916        return BuildOutcome::Deferred;
917    }
918
919    let built = std::panic::catch_unwind(|| {
920        SearchIndex::build(root, respect_gitignore, allow_secret_paths)
921    })
922    .ok()
923    .flatten();
924
925    let mut map = cache()
926        .lock()
927        .unwrap_or_else(std::sync::PoisonError::into_inner);
928    if let Some(entry) = map.get_mut(root) {
929        entry.building = false;
930        if let Some(idx) = built {
931            entry.index = Some(Arc::new(idx));
932            // A just-built index is current with the disk it walked.
933            entry.last_verified = Some(Instant::now());
934        }
935    }
936    enforce_root_cap(&mut map, root);
937    // `lock` is held until here so the cross-process guard spans the whole walk.
938    drop(lock);
939    BuildOutcome::Built
940}
941
942fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
943    std::thread::spawn(move || {
944        let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
945    });
946}
947
948#[cfg(test)]
949#[path = "search_index_tests.rs"]
950mod tests;