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    // ent2-3: files the Hive policy keeps out of context never enter the
534    // index; a policy that cannot be trusted builds none (the search walk
535    // then refuses, fail closed).
536    let hive = crate::core::policy::hive_io::rules_for(root_path).ok()?;
537    let walker = WalkBuilder::new(root_path)
538        // #1792: same corpus rule as the BM25 walk above — see
539        // `walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK`.
540        .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
541        .max_depth(Some(MAX_WALK_DEPTH))
542        .git_ignore(respect_gitignore)
543        .git_global(respect_gitignore)
544        .git_exclude(respect_gitignore)
545        .require_git(false)
546        .filter_entry(crate::core::walk_filter::keep_entry)
547        .build();
548
549    for entry in walker.filter_map(std::result::Result::ok) {
550        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
551            continue;
552        }
553        if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
554            continue;
555        }
556        let path = entry.path();
557        if is_binary_ext(path) || is_generated_file(path) {
558            continue;
559        }
560        if let Some(includes) = includes {
561            if !includes.is_empty() && !glob_matches(path, includes, root_path) {
562                continue;
563            }
564        }
565        if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
566            continue;
567        }
568        if hive
569            .as_ref()
570            .is_some_and(|hive| hive.refusal(path).is_some())
571        {
572            continue;
573        }
574        // `metadata` (stat) never opens the file, so it cannot block on a
575        // FIFO/socket/device node (#336); those are filtered out here.
576        let state = match std::fs::metadata(path) {
577            Ok(meta) if !meta.file_type().is_file() => continue,
578            Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
579            Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
580            Err(_) => continue,
581        };
582        if !visit(path, state) {
583            break;
584        }
585    }
586    Some(())
587}
588
589/// Stable per-file contribution to the corpus signature: folds the path with its
590/// `(mtime, size)` identity. Order-independent (callers combine with
591/// `wrapping_add`), so traversal order never affects the result. A file with no
592/// resolvable mtime contributes its path only — add/rename/delete are still
593/// detected, while an in-place edit on such an exotic filesystem is the same
594/// blind spot the `(mtime, size)` content cache already documents.
595fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
596    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
597    for &b in path.as_os_str().as_encoded_bytes() {
598        h ^= u64::from(b);
599        h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime
600    }
601    if let Some(st) = state {
602        h ^= mix64(st.mtime_ms).rotate_left(1);
603        h ^= mix64(st.size_bytes).rotate_left(33);
604    }
605    mix64(h)
606}
607
608/// Fold the order-independent per-file sum together with the file count into the
609/// final signature, so adding and removing files whose hashes cancel under
610/// addition still changes the result.
611fn finalize_sig(sum: u64, count: usize) -> u64 {
612    sum ^ mix64(count as u64).rotate_left(32)
613}
614
615/// Cheap, stat-only signature of the *current* on-disk corpus for `root`.
616/// Compared against `SearchIndex::signature` in [`get_fresh`] to detect any
617/// change — content edits (including via tools lean-ctx never observes),
618/// additions, renames and deletions — so a stale candidate set can never
619/// silently drop a real match. `None` mirrors a non-indexable root (the caller
620/// then walks directly).
621pub fn corpus_signature(
622    root: &str,
623    respect_gitignore: bool,
624    allow_secret_paths: bool,
625) -> Option<u64> {
626    let mut sum: u64 = 0;
627    let mut count: usize = 0;
628    walk_index_corpus(
629        root,
630        respect_gitignore,
631        allow_secret_paths,
632        None,
633        |path, state| {
634            sum = sum.wrapping_add(file_sig(path, state));
635            count += 1;
636            true
637        },
638    )?;
639    Some(finalize_sig(sum, count))
640}
641
642// ---------------------------------------------------------------------------
643// Resident cache (one index per project root) with background (re)build.
644// ---------------------------------------------------------------------------
645
646struct CacheEntry {
647    index: Option<Arc<SearchIndex>>,
648    building: bool,
649    /// When this root's resident index was last confirmed current against disk.
650    /// Gates the optional coalesce window in [`get_fresh`]; `None` forces a fresh
651    /// verification on the next lookup.
652    last_verified: Option<Instant>,
653}
654
655impl CacheEntry {
656    fn empty() -> Self {
657        Self {
658            index: None,
659            building: false,
660            last_verified: None,
661        }
662    }
663}
664
665static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
666
667fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
668    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
669}
670
671/// Max project roots with a resident index. The daemon builds one for every root
672/// its sessions touch (worktrees, branches, sibling repos) and kept each of them
673/// for its whole lifetime (#2075). LRU-evict beyond this, as `graph_cache` does;
674/// an evicted root walks once and rebuilds on its next search.
675const MAX_RESIDENT_ROOTS: usize = 8;
676
677/// Keep at most [`MAX_RESIDENT_ROOTS`] resident indexes, dropping the least
678/// recently verified first. `keep` (the root just installed) is never dropped.
679fn enforce_root_cap(map: &mut HashMap<String, CacheEntry>, keep: &str) {
680    while map.values().filter(|e| e.index.is_some()).count() > MAX_RESIDENT_ROOTS {
681        let Some(lru) = map
682            .iter()
683            .filter(|(root, e)| e.index.is_some() && root.as_str() != keep)
684            .min_by_key(|(_, e)| e.last_verified)
685            .map(|(root, _)| root.clone())
686        else {
687            break;
688        };
689        // An in-flight rebuild keeps its entry so its `building` flag survives.
690        match map.get_mut(&lru) {
691            Some(entry) if entry.building => {
692                entry.index = None;
693                entry.last_verified = None;
694            }
695            _ => {
696                map.remove(&lru);
697            }
698        }
699    }
700}
701
702/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
703/// everywhere (debugging / A-B measurement / opt-out).
704fn index_disabled() -> bool {
705    std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
706        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
707}
708
709/// Optional coalescing window for the freshness check. Default `0` (disabled):
710/// every [`get_fresh`] re-verifies the corpus signature against disk, so a fresh
711/// edit is never missed. On very large indexed trees, set
712/// `LEAN_CTX_SEARCH_INDEX_COALESCE_MS` to trade a bounded staleness window for
713/// fewer stat-walks under bursty search load.
714fn coalesce_window() -> Option<Duration> {
715    let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
716        .ok()
717        .and_then(|v| v.trim().parse::<u64>().ok())
718        .unwrap_or(0);
719    (ms > 0).then(|| Duration::from_millis(ms))
720}
721
722/// Returns a fresh resident index for `root` if one is available for the given
723/// config, otherwise spawns a background (re)build and returns `None` so the
724/// caller uses the walk fallback for this call.
725pub fn get_fresh(
726    root: &str,
727    respect_gitignore: bool,
728    allow_secret_paths: bool,
729) -> Option<Arc<SearchIndex>> {
730    // Privileged "ignore gitignore" scans are rare and bypass the index.
731    if !respect_gitignore || index_disabled() {
732        return None;
733    }
734
735    // Phase 1 (locked, O(1)): grab the resident index for this config, if any,
736    // together with when it was last verified against disk.
737    let (candidate, last_verified) = {
738        let map = cache()
739            .lock()
740            .unwrap_or_else(std::sync::PoisonError::into_inner);
741        match map.get(root) {
742            Some(entry) => match &entry.index {
743                Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
744                    (Some(Arc::clone(idx)), entry.last_verified)
745                }
746                _ => (None, None),
747            },
748            None => (None, None),
749        }
750    };
751
752    let Some(idx) = candidate else {
753        // No usable index yet → build in the background, walk this call.
754        request_build(root, respect_gitignore, allow_secret_paths);
755        return None;
756    };
757
758    // Coalesce (opt-in, default off): inside the window trust the recent
759    // verification and skip the stat-walk — keeps bursty search load O(1).
760    if let Some(window) = coalesce_window()
761        && last_verified.is_some_and(|t| t.elapsed() < window)
762    {
763        return Some(idx);
764    }
765
766    // Phase 2 (unlocked): verify the live corpus signature. Deliberately held
767    // *outside* the cache mutex so a multi-millisecond stat-walk never serializes
768    // other roots or concurrent searches.
769    match corpus_signature(root, respect_gitignore, allow_secret_paths) {
770        Some(sig) if sig == idx.signature => {
771            mark_verified(root);
772            Some(idx)
773        }
774        _ => {
775            // Corpus changed (or root no longer indexable): walk accurately now
776            // and rebuild the index in the background for the next call.
777            request_build(root, respect_gitignore, allow_secret_paths);
778            None
779        }
780    }
781}
782
783/// Drop every resident trigram index (#685 eviction hook). In-flight builds
784/// keep their `building` flag (the entry is reset, not the build thread), so
785/// a running build still installs its result; subsequent searches fall back
786/// to the walk path until the next `ensure_background` rebuilds. Correctness
787/// is unaffected — the resident index is purely an accelerator. Returns whether
788/// any index was dropped.
789pub fn clear_resident() -> bool {
790    let mut map = cache()
791        .lock()
792        .unwrap_or_else(std::sync::PoisonError::into_inner);
793    let mut dropped = false;
794    for entry in map.values_mut() {
795        dropped |= entry.index.take().is_some();
796        entry.last_verified = None;
797    }
798    dropped
799}
800
801/// Record that `root`'s resident index was just confirmed current, extending its
802/// coalesce window.
803fn mark_verified(root: &str) {
804    let mut map = cache()
805        .lock()
806        .unwrap_or_else(std::sync::PoisonError::into_inner);
807    if let Some(entry) = map.get_mut(root) {
808        entry.last_verified = Some(Instant::now());
809    }
810}
811
812/// Spawn a background (re)build for `root` unless one is already in flight.
813fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
814    let needs_build = {
815        let mut map = cache()
816            .lock()
817            .unwrap_or_else(std::sync::PoisonError::into_inner);
818        let entry = map
819            .entry(root.to_string())
820            .or_insert_with(CacheEntry::empty);
821        if entry.building {
822            false
823        } else {
824            entry.building = true;
825            true
826        }
827    };
828    if needs_build {
829        spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
830    }
831}
832
833/// Ensure a resident index for `root` is built (or building) in the background.
834/// Safe to call repeatedly; deduped via the per-root `building` flag.
835pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
836    if !respect_gitignore || index_disabled() {
837        return;
838    }
839    // Prewarm only when there is no usable index yet — staleness of an existing
840    // index is corrected on demand by `get_fresh`'s signature gate, so there is
841    // nothing to proactively refresh here.
842    let has_index = {
843        let map = cache()
844            .lock()
845            .unwrap_or_else(std::sync::PoisonError::into_inner);
846        map.get(root).is_some_and(|entry| {
847            entry
848                .index
849                .as_ref()
850                .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
851        })
852    };
853    if !has_index {
854        request_build(root, respect_gitignore, allow_secret_paths);
855    }
856}
857
858/// Build the index synchronously and install it in the resident cache.
859/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
860/// guaranteed-warm index. Respects the disable env var.
861pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
862    if !respect_gitignore || index_disabled() {
863        return false;
864    }
865    let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
866        return false;
867    };
868    let mut map = cache()
869        .lock()
870        .unwrap_or_else(std::sync::PoisonError::into_inner);
871    map.insert(
872        root.to_string(),
873        CacheEntry {
874            index: Some(Arc::new(idx)),
875            building: false,
876            last_verified: Some(Instant::now()),
877        },
878    );
879    enforce_root_cap(&mut map, root);
880    true
881}
882
883/// Per-repo lock name serializing the resident search-index build across
884/// processes, mirroring the `graph-idx` / `bm25-idx` locks in
885/// [`crate::core::index_orchestrator`]. Distinct `search-` prefix so the three
886/// indexers never serialize against one another.
887fn search_index_lock_name(root: &str) -> String {
888    format!(
889        "search-idx-{}",
890        &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
891    )
892}
893
894/// Outcome of a guarded background build: did this process do the walk, or did
895/// it yield to another process already building the same root?
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897enum BuildOutcome {
898    Built,
899    Deferred,
900}
901
902/// Build the resident index under a cross-process herd guard (#460).
903///
904/// The trigram index is RAM-resident (not shareable on disk), so on lock
905/// contention we *defer* the proactive pre-warm instead of running a second
906/// simultaneous file walk: a boot wave of N sessions on one repo then triggers
907/// ~1 walk at a time, not N. Deferring is safe — `ctx_search` still works via
908/// its walk fallback, and the per-process `building` flag is cleared so the next
909/// `get_fresh` / `ensure_background` nudge retries once the holder releases. The
910/// short 200 ms wait keeps the common single-session path latency-free.
911fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
912    let lock = crate::core::startup_guard::try_acquire_lock(
913        &search_index_lock_name(root),
914        Duration::from_millis(200),
915        Duration::from_mins(3),
916    );
917    if lock.is_none() {
918        // Another process owns the build. Clear the in-flight flag so a later
919        // nudge retries rather than leaving `building` stuck true forever.
920        let mut map = cache()
921            .lock()
922            .unwrap_or_else(std::sync::PoisonError::into_inner);
923        if let Some(entry) = map.get_mut(root) {
924            entry.building = false;
925        }
926        return BuildOutcome::Deferred;
927    }
928
929    let built = std::panic::catch_unwind(|| {
930        SearchIndex::build(root, respect_gitignore, allow_secret_paths)
931    })
932    .ok()
933    .flatten();
934
935    let mut map = cache()
936        .lock()
937        .unwrap_or_else(std::sync::PoisonError::into_inner);
938    if let Some(entry) = map.get_mut(root) {
939        entry.building = false;
940        if let Some(idx) = built {
941            entry.index = Some(Arc::new(idx));
942            // A just-built index is current with the disk it walked.
943            entry.last_verified = Some(Instant::now());
944        }
945    }
946    enforce_root_cap(&mut map, root);
947    // `lock` is held until here so the cross-process guard spans the whole walk.
948    drop(lock);
949    BuildOutcome::Built
950}
951
952fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
953    std::thread::spawn(move || {
954        // The build's project is the root it indexes: its files are the
955        // project's own, not unknown stored text (pz21 §1.4), whatever the
956        // process directory of this detached thread is.
957        crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
958            std::cell::RefCell::new(Some(std::path::PathBuf::from(&root))),
959            || {
960                let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
961            },
962        );
963    });
964}
965
966#[cfg(test)]
967#[path = "search_index_tests.rs"]
968mod tests;