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 [`crate::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::tools::ctx_search::{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}
155
156impl SearchIndex {
157    /// Build the index by walking `root` with the exact same config and filters
158    /// as `ctx_search`, so the searchable file universe is identical.
159    pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
160        let mut files: Vec<PathBuf> = Vec::new();
161        // Per-file sorted, deduped trigrams. Same memory as the posting lists
162        // would be, but grouped by file so we can materialise *either* tier
163        // afterwards without a second pass over the corpus.
164        let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
165        let mut total_entries: usize = 0;
166        let mut scratch: HashSet<u32> = HashSet::new();
167        // Corpus-signature accumulators. The running sum is order-independent
168        // (commutative `wrapping_add`) and the file count is folded in at the end
169        // so a change that happens to cancel under addition is still detected.
170        // Folded over the *eligible-by-stat* universe — before the read below —
171        // so the stat-only re-walk in [`corpus_signature`] reproduces it exactly,
172        // including files that turn out to be non-UTF-8 (counted, never indexed).
173        let mut sig_sum: u64 = 0;
174        let mut file_count: usize = 0;
175        let mut aborted = false;
176
177        walk_index_corpus(
178            root,
179            respect_gitignore,
180            allow_secret_paths,
181            |path, state| {
182                sig_sum = sig_sum.wrapping_add(file_sig(path, state));
183                file_count += 1;
184
185                // Read the corpus exactly once (issue #148): reuse a fresh cached
186                // copy if a prior `ctx_search`/build already read this file, else
187                // read it now and publish it so the upcoming `ctx_search` verify
188                // pass is an in-memory hit instead of a second disk read. Mirrors
189                // ctx_search: a non-UTF-8 file is never searchable, so it is skipped
190                // for trigrams (but already folded into the signature above).
191                let content: std::sync::Arc<str> = if let Some(cached) =
192                    state.and_then(|s| crate::core::content_cache::get(path, s))
193                {
194                    cached
195                } else {
196                    let Ok(text) = std::fs::read_to_string(path) else {
197                        return true;
198                    };
199                    let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
200                    if let Some(s) = state {
201                        crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
202                    }
203                    arc
204                };
205
206                if files.len() >= MAX_FILES {
207                    aborted = true; // too large even for the Bloom tier — use the walk
208                    return false;
209                }
210
211                scratch.clear();
212                let bytes = content.as_bytes();
213                if bytes.len() >= 3 {
214                    for w in bytes.windows(3) {
215                        if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
216                            scratch.insert(pack(w[0], w[1], w[2]));
217                        }
218                    }
219                }
220                total_entries += scratch.len();
221                if total_entries > MAX_TOTAL_ENTRIES {
222                    aborted = true; // memory guard — fall back to walk
223                    return false;
224                }
225                let mut tris: Vec<u32> = scratch.iter().copied().collect();
226                tris.sort_unstable();
227                files.push(path.to_path_buf());
228                per_file_trigrams.push(tris);
229                true
230            },
231        )?;
232
233        if aborted {
234            return None;
235        }
236
237        let narrowing = build_narrowing(&per_file_trigrams, total_entries);
238
239        Some(Self {
240            files,
241            narrowing,
242            respect_gitignore,
243            allow_secret_paths,
244            signature: finalize_sig(sig_sum, file_count),
245        })
246    }
247
248    fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
249        self.respect_gitignore == respect_gitignore && self.allow_secret_paths == allow_secret_paths
250    }
251
252    /// Candidate files for `pattern`, filtered by the `include` glob (matched
253    /// against each file's path relative to `root`). `None` means "no safe
254    /// narrowing possible" — the caller should scan the full file list.
255    ///
256    /// Narrowing is applied only for pure `[A-Za-z0-9_]` literals of length ≥ 3.
257    /// For such a literal every match contains it on a single line, hence the
258    /// file contains all of its consecutive trigrams: intersecting their
259    /// posting lists yields a *superset* of matching files (zero false
260    /// negatives), which the caller then regex-verifies.
261    pub fn candidate_paths(
262        &self,
263        pattern: &str,
264        includes: &[Pattern],
265        root: &Path,
266    ) -> CandidateSet {
267        if let Some(ids) = self.literal_candidates(pattern) {
268            let paths = ids
269                .into_iter()
270                .map(|id| self.files[id as usize].clone())
271                .filter(|p| glob_matches(p, includes, root))
272                .collect();
273            CandidateSet::Narrowed(paths)
274        } else {
275            let paths = self
276                .files
277                .iter()
278                .filter(|p| glob_matches(p, includes, root))
279                .cloned()
280                .collect();
281            CandidateSet::FullList(paths)
282        }
283    }
284
285    /// Returns candidate file ids for a pure-literal query, or `None` if the
286    /// query is not a trigram-narrowable pure `[A-Za-z0-9_]` literal. Both tiers
287    /// return a *superset* of true matches (zero false negatives).
288    fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
289        let bytes = pattern.as_bytes();
290        if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
291            return None;
292        }
293        // Distinct trigrams of the literal.
294        let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
295        tris.sort_unstable();
296        tris.dedup();
297
298        match &self.narrowing {
299            Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
300            Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
301        }
302    }
303
304    /// Exact-tier: intersect the posting lists of every required trigram
305    /// (smallest first for a cheap intersection).
306    fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
307        let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
308        for &tri in tris {
309            match trigrams.get(&tri) {
310                // A required trigram is absent → provably no match anywhere.
311                None => return Vec::new(),
312                Some(list) => lists.push(list),
313            }
314        }
315        lists.sort_by_key(|l| l.len());
316
317        let mut acc: Vec<u32> = lists[0].clone();
318        for list in &lists[1..] {
319            acc = intersect_sorted(&acc, list);
320            if acc.is_empty() {
321                break;
322            }
323        }
324        acc
325    }
326
327    /// Bloom-tier: a file is a candidate iff its Bloom filter may contain every
328    /// required trigram. No false negatives (an unset probe bit ⇒ the trigram is
329    /// provably absent), so the result is still a superset of true matches.
330    fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
331        let mut out = Vec::new();
332        for (fid, bloom) in blooms.iter().enumerate() {
333            if tris.iter().all(|&t| bloom.maybe_contains(t)) {
334                out.push(fid as u32);
335            }
336        }
337        out
338    }
339}
340
341/// Materialise the appropriate narrowing tier for a freshly walked corpus.
342fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
343    if total_entries <= MAX_POSTING_ENTRIES {
344        let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
345        for (fid, tris) in per_file.iter().enumerate() {
346            for &t in tris {
347                // file ids are appended in ascending order ⇒ lists stay sorted.
348                trigrams.entry(t).or_default().push(fid as u32);
349            }
350        }
351        Narrowing::Postings(trigrams)
352    } else {
353        let blooms = per_file
354            .iter()
355            .map(|tris| {
356                let mut b = FileBloom::with_capacity(tris.len());
357                for &t in tris {
358                    b.insert(t);
359                }
360                b
361            })
362            .collect();
363        Narrowing::Blooms(blooms)
364    }
365}
366
367/// Result of [`SearchIndex::candidate_paths`].
368pub enum CandidateSet {
369    /// Trigram-narrowed candidate files (a superset of real matches).
370    Narrowed(Vec<PathBuf>),
371    /// No safe narrowing — the full cached file list (still skips the walk).
372    FullList(Vec<PathBuf>),
373}
374
375impl CandidateSet {
376    pub fn into_paths(self) -> Vec<PathBuf> {
377        match self {
378            CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
379        }
380    }
381}
382
383/// True when `path` matches *any* of the `includes` globs (relative to `root`),
384/// or when there is no filter (`includes` empty).
385fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
386    if includes.is_empty() {
387        return true;
388    }
389    let rel = path.strip_prefix(root).unwrap_or(path);
390    let rel_str = rel.to_string_lossy();
391    includes.iter().any(|p| p.matches(&rel_str))
392}
393
394/// Intersection of two ascending, deduped `u32` slices.
395fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
396    let mut out = Vec::new();
397    let (mut i, mut j) = (0, 0);
398    while i < a.len() && j < b.len() {
399        match a[i].cmp(&b[j]) {
400            std::cmp::Ordering::Less => i += 1,
401            std::cmp::Ordering::Greater => j += 1,
402            std::cmp::Ordering::Equal => {
403                out.push(a[i]);
404                i += 1;
405                j += 1;
406            }
407        }
408    }
409    out
410}
411
412// ---------------------------------------------------------------------------
413// Corpus freshness: the build and its freshness check share one traversal so
414// the signature is computed over an identical file universe (parity is what
415// makes the equality check trustworthy).
416// ---------------------------------------------------------------------------
417
418/// Walk `root` with the *exact* `ctx_search` file universe — same gitignore
419/// semantics, depth limit, and binary/generated/secret/size/regular-file guards
420/// — invoking `visit(path, state)` for every eligible regular file with its
421/// `(mtime, size)` identity (`None` only when the platform cannot report mtime).
422/// `visit` returns `false` to stop early. Returns `None` for a missing or unsafe
423/// scan root (HOME, fs root, …), the same guard that makes the caller fall back
424/// to a direct walk. This traversal reads nothing: it is the shared spine of
425/// both [`SearchIndex::build`] and [`corpus_signature`].
426fn walk_index_corpus<F>(
427    root: &str,
428    respect_gitignore: bool,
429    allow_secret_paths: bool,
430    mut visit: F,
431) -> Option<()>
432where
433    F: FnMut(&Path, Option<crate::core::content_cache::FileState>) -> bool,
434{
435    let root_path = Path::new(root);
436    if !root_path.exists() {
437        return None;
438    }
439    // Never auto-index a broad/unsafe root (HOME, filesystem root, a dir with
440    // dozens of unrelated subtrees). Mirrors the graph/BM25 guard and stops a
441    // walk of the whole home directory — which on Windows would hydrate OneDrive
442    // placeholders (#363).
443    if !crate::core::graph_index::is_safe_scan_root_public(root) {
444        return None;
445    }
446
447    let walker = WalkBuilder::new(root_path)
448        .hidden(true)
449        .max_depth(Some(MAX_WALK_DEPTH))
450        .git_ignore(respect_gitignore)
451        .git_global(respect_gitignore)
452        .git_exclude(respect_gitignore)
453        .require_git(false)
454        .filter_entry(crate::core::walk_filter::keep_entry)
455        .build();
456
457    for entry in walker.filter_map(std::result::Result::ok) {
458        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
459            continue;
460        }
461        if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
462            continue;
463        }
464        let path = entry.path();
465        if is_binary_ext(path) || is_generated_file(path) {
466            continue;
467        }
468        if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
469            continue;
470        }
471        // `metadata` (stat) never opens the file, so it cannot block on a
472        // FIFO/socket/device node (#336); those are filtered out here.
473        let state = match std::fs::metadata(path) {
474            Ok(meta) if !meta.file_type().is_file() => continue,
475            Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
476            Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
477            Err(_) => continue,
478        };
479        if !visit(path, state) {
480            break;
481        }
482    }
483    Some(())
484}
485
486/// Stable per-file contribution to the corpus signature: folds the path with its
487/// `(mtime, size)` identity. Order-independent (callers combine with
488/// `wrapping_add`), so traversal order never affects the result. A file with no
489/// resolvable mtime contributes its path only — add/rename/delete are still
490/// detected, while an in-place edit on such an exotic filesystem is the same
491/// blind spot the `(mtime, size)` content cache already documents.
492fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
493    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
494    for &b in path.as_os_str().as_encoded_bytes() {
495        h ^= u64::from(b);
496        h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime
497    }
498    if let Some(st) = state {
499        h ^= mix64(st.mtime_ms).rotate_left(1);
500        h ^= mix64(st.size_bytes).rotate_left(33);
501    }
502    mix64(h)
503}
504
505/// Fold the order-independent per-file sum together with the file count into the
506/// final signature, so adding and removing files whose hashes cancel under
507/// addition still changes the result.
508fn finalize_sig(sum: u64, count: usize) -> u64 {
509    sum ^ mix64(count as u64).rotate_left(32)
510}
511
512/// Cheap, stat-only signature of the *current* on-disk corpus for `root`.
513/// Compared against [`SearchIndex::signature`] in [`get_fresh`] to detect any
514/// change — content edits (including via tools lean-ctx never observes),
515/// additions, renames and deletions — so a stale candidate set can never
516/// silently drop a real match. `None` mirrors a non-indexable root (the caller
517/// then walks directly).
518fn corpus_signature(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<u64> {
519    let mut sum: u64 = 0;
520    let mut count: usize = 0;
521    walk_index_corpus(
522        root,
523        respect_gitignore,
524        allow_secret_paths,
525        |path, state| {
526            sum = sum.wrapping_add(file_sig(path, state));
527            count += 1;
528            true
529        },
530    )?;
531    Some(finalize_sig(sum, count))
532}
533
534// ---------------------------------------------------------------------------
535// Resident cache (one index per project root) with background (re)build.
536// ---------------------------------------------------------------------------
537
538struct CacheEntry {
539    index: Option<Arc<SearchIndex>>,
540    building: bool,
541    /// When this root's resident index was last confirmed current against disk.
542    /// Gates the optional coalesce window in [`get_fresh`]; `None` forces a fresh
543    /// verification on the next lookup.
544    last_verified: Option<Instant>,
545}
546
547impl CacheEntry {
548    fn empty() -> Self {
549        Self {
550            index: None,
551            building: false,
552            last_verified: None,
553        }
554    }
555}
556
557static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
558
559fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
560    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
561}
562
563/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
564/// everywhere (debugging / A-B measurement / opt-out).
565fn index_disabled() -> bool {
566    std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
567        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
568}
569
570/// Optional coalescing window for the freshness check. Default `0` (disabled):
571/// every [`get_fresh`] re-verifies the corpus signature against disk, so a fresh
572/// edit is never missed. On very large indexed trees, set
573/// `LEAN_CTX_SEARCH_INDEX_COALESCE_MS` to trade a bounded staleness window for
574/// fewer stat-walks under bursty search load.
575fn coalesce_window() -> Option<Duration> {
576    let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
577        .ok()
578        .and_then(|v| v.trim().parse::<u64>().ok())
579        .unwrap_or(0);
580    (ms > 0).then(|| Duration::from_millis(ms))
581}
582
583/// Returns a fresh resident index for `root` if one is available for the given
584/// config, otherwise spawns a background (re)build and returns `None` so the
585/// caller uses the walk fallback for this call.
586pub fn get_fresh(
587    root: &str,
588    respect_gitignore: bool,
589    allow_secret_paths: bool,
590) -> Option<Arc<SearchIndex>> {
591    // Privileged "ignore gitignore" scans are rare and bypass the index.
592    if !respect_gitignore || index_disabled() {
593        return None;
594    }
595
596    // Phase 1 (locked, O(1)): grab the resident index for this config, if any,
597    // together with when it was last verified against disk.
598    let (candidate, last_verified) = {
599        let map = cache()
600            .lock()
601            .unwrap_or_else(std::sync::PoisonError::into_inner);
602        match map.get(root) {
603            Some(entry) => match &entry.index {
604                Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
605                    (Some(Arc::clone(idx)), entry.last_verified)
606                }
607                _ => (None, None),
608            },
609            None => (None, None),
610        }
611    };
612
613    let Some(idx) = candidate else {
614        // No usable index yet → build in the background, walk this call.
615        request_build(root, respect_gitignore, allow_secret_paths);
616        return None;
617    };
618
619    // Coalesce (opt-in, default off): inside the window trust the recent
620    // verification and skip the stat-walk — keeps bursty search load O(1).
621    if let Some(window) = coalesce_window()
622        && last_verified.is_some_and(|t| t.elapsed() < window)
623    {
624        return Some(idx);
625    }
626
627    // Phase 2 (unlocked): verify the live corpus signature. Deliberately held
628    // *outside* the cache mutex so a multi-millisecond stat-walk never serializes
629    // other roots or concurrent searches.
630    match corpus_signature(root, respect_gitignore, allow_secret_paths) {
631        Some(sig) if sig == idx.signature => {
632            mark_verified(root);
633            Some(idx)
634        }
635        _ => {
636            // Corpus changed (or root no longer indexable): walk accurately now
637            // and rebuild the index in the background for the next call.
638            request_build(root, respect_gitignore, allow_secret_paths);
639            None
640        }
641    }
642}
643
644/// Drop every resident trigram index (#685 eviction hook). In-flight builds
645/// keep their `building` flag (the entry is reset, not the build thread), so
646/// a running build still installs its result; subsequent searches fall back
647/// to the walk path until the next `ensure_background` rebuilds. Correctness
648/// is unaffected — the resident index is purely an accelerator.
649pub fn clear_resident() {
650    let mut map = cache()
651        .lock()
652        .unwrap_or_else(std::sync::PoisonError::into_inner);
653    for entry in map.values_mut() {
654        entry.index = None;
655        entry.last_verified = None;
656    }
657}
658
659/// Record that `root`'s resident index was just confirmed current, extending its
660/// coalesce window.
661fn mark_verified(root: &str) {
662    let mut map = cache()
663        .lock()
664        .unwrap_or_else(std::sync::PoisonError::into_inner);
665    if let Some(entry) = map.get_mut(root) {
666        entry.last_verified = Some(Instant::now());
667    }
668}
669
670/// Spawn a background (re)build for `root` unless one is already in flight.
671fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
672    let needs_build = {
673        let mut map = cache()
674            .lock()
675            .unwrap_or_else(std::sync::PoisonError::into_inner);
676        let entry = map
677            .entry(root.to_string())
678            .or_insert_with(CacheEntry::empty);
679        if entry.building {
680            false
681        } else {
682            entry.building = true;
683            true
684        }
685    };
686    if needs_build {
687        spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
688    }
689}
690
691/// Ensure a resident index for `root` is built (or building) in the background.
692/// Safe to call repeatedly; deduped via the per-root `building` flag.
693pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
694    if !respect_gitignore || index_disabled() {
695        return;
696    }
697    // Prewarm only when there is no usable index yet — staleness of an existing
698    // index is corrected on demand by `get_fresh`'s signature gate, so there is
699    // nothing to proactively refresh here.
700    let has_index = {
701        let map = cache()
702            .lock()
703            .unwrap_or_else(std::sync::PoisonError::into_inner);
704        map.get(root).is_some_and(|entry| {
705            entry
706                .index
707                .as_ref()
708                .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
709        })
710    };
711    if !has_index {
712        request_build(root, respect_gitignore, allow_secret_paths);
713    }
714}
715
716/// Build the index synchronously and install it in the resident cache.
717/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
718/// guaranteed-warm index. Respects the disable env var.
719pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
720    if !respect_gitignore || index_disabled() {
721        return false;
722    }
723    let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
724        return false;
725    };
726    let mut map = cache()
727        .lock()
728        .unwrap_or_else(std::sync::PoisonError::into_inner);
729    map.insert(
730        root.to_string(),
731        CacheEntry {
732            index: Some(Arc::new(idx)),
733            building: false,
734            last_verified: Some(Instant::now()),
735        },
736    );
737    true
738}
739
740/// Per-repo lock name serializing the resident search-index build across
741/// processes, mirroring the `graph-idx` / `bm25-idx` locks in
742/// [`crate::core::index_orchestrator`]. Distinct `search-` prefix so the three
743/// indexers never serialize against one another.
744fn search_index_lock_name(root: &str) -> String {
745    format!(
746        "search-idx-{}",
747        &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
748    )
749}
750
751/// Outcome of a guarded background build: did this process do the walk, or did
752/// it yield to another process already building the same root?
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754enum BuildOutcome {
755    Built,
756    Deferred,
757}
758
759/// Build the resident index under a cross-process herd guard (#460).
760///
761/// The trigram index is RAM-resident (not shareable on disk), so on lock
762/// contention we *defer* the proactive pre-warm instead of running a second
763/// simultaneous file walk: a boot wave of N sessions on one repo then triggers
764/// ~1 walk at a time, not N. Deferring is safe — `ctx_search` still works via
765/// its walk fallback, and the per-process `building` flag is cleared so the next
766/// `get_fresh` / `ensure_background` nudge retries once the holder releases. The
767/// short 200 ms wait keeps the common single-session path latency-free.
768fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
769    let lock = crate::core::startup_guard::try_acquire_lock(
770        &search_index_lock_name(root),
771        Duration::from_millis(200),
772        Duration::from_mins(3),
773    );
774    if lock.is_none() {
775        // Another process owns the build. Clear the in-flight flag so a later
776        // nudge retries rather than leaving `building` stuck true forever.
777        let mut map = cache()
778            .lock()
779            .unwrap_or_else(std::sync::PoisonError::into_inner);
780        if let Some(entry) = map.get_mut(root) {
781            entry.building = false;
782        }
783        return BuildOutcome::Deferred;
784    }
785
786    let built = std::panic::catch_unwind(|| {
787        SearchIndex::build(root, respect_gitignore, allow_secret_paths)
788    })
789    .ok()
790    .flatten();
791
792    let mut map = cache()
793        .lock()
794        .unwrap_or_else(std::sync::PoisonError::into_inner);
795    if let Some(entry) = map.get_mut(root) {
796        entry.building = false;
797        if let Some(idx) = built {
798            entry.index = Some(Arc::new(idx));
799            // A just-built index is current with the disk it walked.
800            entry.last_verified = Some(Instant::now());
801        }
802    }
803    // `lock` is held until here so the cross-process guard spans the whole walk.
804    drop(lock);
805    BuildOutcome::Built
806}
807
808fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
809    std::thread::spawn(move || {
810        let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
811    });
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    fn corpus() -> tempfile::TempDir {
819        let dir = tempfile::tempdir().unwrap();
820        std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
821        std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
822        std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
823        dir
824    }
825
826    #[test]
827    fn build_refuses_to_index_home_directory() {
828        // Auto-indexing HOME would walk the entire home tree and, on Windows,
829        // hydrate every OneDrive placeholder (#363). The build must bail out.
830        if let Some(home) = dirs::home_dir() {
831            assert!(
832                SearchIndex::build(&home.to_string_lossy(), true, false).is_none(),
833                "search index must never auto-build over the home directory"
834            );
835        }
836    }
837
838    #[test]
839    fn narrows_to_files_containing_literal() {
840        let dir = corpus();
841        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
842        let cands = idx.candidate_paths("handler", &[], dir.path());
843        let paths = cands.into_paths();
844        // a.rs and c.txt contain "handler"; b.rs must be excluded.
845        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
846        assert!(paths.iter().any(|p| p.ends_with("c.txt")));
847        assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
848    }
849
850    #[test]
851    fn absent_trigram_yields_empty_candidates() {
852        let dir = corpus();
853        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
854        match idx.candidate_paths("zzzqqq", &[], dir.path()) {
855            CandidateSet::Narrowed(p) => assert!(p.is_empty()),
856            CandidateSet::FullList(_) => panic!("pure literal should narrow"),
857        }
858    }
859
860    #[test]
861    fn ext_filter_restricts_candidates() {
862        let dir = corpus();
863        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
864        let paths = idx
865            .candidate_paths(
866                "handler",
867                &[glob::Pattern::new("*.rs").unwrap()],
868                dir.path(),
869            )
870            .into_paths();
871        assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
872        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
873    }
874
875    #[test]
876    #[cfg(unix)]
877    fn build_skips_named_pipe_without_hanging() {
878        use std::sync::mpsc;
879        use std::time::Duration;
880        // #336: the background index build read every file, so a FIFO in the
881        // corpus blocked the build thread forever. It must be skipped while the
882        // regular files are still indexed, and the build must return.
883        let dir = corpus();
884        let fifo = dir.path().join("pipe.fifo");
885        let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
886        assert_eq!(
887            // SAFETY: `c` is a live CString providing a valid NUL-terminated
888            // path pointer for the duration of the call.
889            unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
890            0,
891            "mkfifo failed"
892        );
893
894        let root = dir.path().to_str().unwrap().to_string();
895        let (tx, rx) = mpsc::channel();
896        std::thread::spawn(move || {
897            let built = SearchIndex::build(&root, true, false);
898            let _ = tx.send(built.map(|idx| {
899                idx.candidate_paths("handler", &[], std::path::Path::new(&root))
900                    .into_paths()
901            }));
902        });
903        let paths = rx
904            .recv_timeout(Duration::from_secs(5))
905            .expect("SearchIndex::build hung on a FIFO (#336 regression)")
906            .expect("index should build");
907        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
908        assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
909    }
910
911    #[test]
912    fn regex_query_falls_back_to_full_list() {
913        let dir = corpus();
914        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
915        match idx.candidate_paths("fn .*\\(\\)", &[], dir.path()) {
916            CandidateSet::FullList(p) => assert!(!p.is_empty()),
917            CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
918        }
919    }
920
921    #[test]
922    fn short_query_falls_back_to_full_list() {
923        let dir = corpus();
924        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
925        assert!(matches!(
926            idx.candidate_paths("fn", &[], dir.path()),
927            CandidateSet::FullList(_)
928        ));
929    }
930
931    /// The core correctness claim: trigram narrowing never drops a real match.
932    /// For each literal query, the set of `file:line` hits found by scanning only
933    /// the narrowed candidates must equal the set found by scanning every file.
934    #[test]
935    fn narrowing_has_identical_recall_to_full_scan() {
936        use regex::Regex;
937        use std::collections::BTreeSet;
938
939        let dir = tempfile::tempdir().unwrap();
940        // A spread of files; some contain the query tokens, most do not.
941        let samples = [
942            (
943                "auth/login.rs",
944                "fn authenticate(user) {}\nlet token = mint();\n",
945            ),
946            (
947                "auth/session.rs",
948                "struct Session;\n// authenticate again here\n",
949            ),
950            ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
951            (
952                "ui/button.tsx",
953                "export const Button = () => authenticate;\n",
954            ),
955            ("readme.md", "This project uses authenticate flows.\n"),
956            ("unrelated.rs", "fn helper() { let v = 1; }\n"),
957        ];
958        for (rel, content) in samples {
959            let p = dir.path().join(rel);
960            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
961            std::fs::write(p, content).unwrap();
962        }
963        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
964
965        let full_scan = |pat: &str| -> BTreeSet<String> {
966            let re = Regex::new(pat).unwrap();
967            let mut hits = BTreeSet::new();
968            for (rel, content) in samples {
969                for (i, line) in content.lines().enumerate() {
970                    if re.is_match(line) {
971                        hits.insert(format!("{rel}:{}", i + 1));
972                    }
973                }
974            }
975            hits
976        };
977
978        for query in ["authenticate", "Session", "retries", "token"] {
979            let re = Regex::new(query).unwrap();
980            let candidates = idx.candidate_paths(query, &[], dir.path()).into_paths();
981            let mut narrowed = BTreeSet::new();
982            for path in &candidates {
983                let content = std::fs::read_to_string(path).unwrap();
984                let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
985                for (i, line) in content.lines().enumerate() {
986                    if re.is_match(line) {
987                        narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
988                    }
989                }
990            }
991            assert_eq!(
992                narrowed,
993                full_scan(query),
994                "recall mismatch for query {query:?}"
995            );
996        }
997    }
998
999    #[test]
1000    fn intersect_sorted_basic() {
1001        assert_eq!(
1002            intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
1003            vec![2, 3, 5]
1004        );
1005        assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
1006    }
1007
1008    // ── Bloom tier ────────────────────────────────────────────────────────
1009
1010    fn trigrams_of(s: &str) -> Vec<u32> {
1011        let mut set = HashSet::new();
1012        let b = s.as_bytes();
1013        if b.len() >= 3 {
1014            for w in b.windows(3) {
1015                if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
1016                    set.insert(pack(w[0], w[1], w[2]));
1017                }
1018            }
1019        }
1020        let mut v: Vec<u32> = set.into_iter().collect();
1021        v.sort_unstable();
1022        v
1023    }
1024
1025    #[test]
1026    fn file_bloom_has_no_false_negatives() {
1027        let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
1028        let mut bloom = FileBloom::with_capacity(tris.len());
1029        for &t in &tris {
1030            bloom.insert(t);
1031        }
1032        // Every inserted trigram must be reported present (Bloom guarantee).
1033        assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
1034    }
1035
1036    /// Parity fuzz: the Bloom tier must return a *superset* of the exact posting
1037    /// tier for every query (zero false negatives). False positives are allowed
1038    /// (and verified away downstream), so we assert containment, not equality.
1039    #[test]
1040    fn bloom_tier_is_superset_of_postings_tier() {
1041        // Deterministic synthetic corpus (LCG → reproducible).
1042        let mut seed = 0x1234_5678_9abc_def0u64;
1043        let mut rng = || {
1044            seed = seed
1045                .wrapping_mul(6364136223846793005)
1046                .wrapping_add(1442695040888963407);
1047            (seed >> 33) as u32
1048        };
1049        let mut per_file: Vec<Vec<u32>> = Vec::new();
1050        for _ in 0..80 {
1051            let n = 50 + (rng() % 250) as usize;
1052            let mut s = HashSet::new();
1053            for _ in 0..n {
1054                s.insert(rng() & 0x00FF_FFFF);
1055            }
1056            let mut v: Vec<u32> = s.into_iter().collect();
1057            v.sort_unstable();
1058            per_file.push(v);
1059        }
1060        let total: usize = per_file.iter().map(Vec::len).sum();
1061
1062        let postings = build_narrowing(&per_file, total); // ≤ cap → postings
1063        let blooms = build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1); // forced bloom
1064        let (Narrowing::Postings(pt), Narrowing::Blooms(bl)) = (&postings, &blooms) else {
1065            panic!("unexpected narrowing tiers");
1066        };
1067
1068        // Queries drawn from real file trigrams (these MUST be found by both),
1069        // plus a few that are unlikely to exist anywhere.
1070        for f in &per_file {
1071            if f.len() < 3 {
1072                continue;
1073            }
1074            let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
1075            let exact = SearchIndex::postings_intersect(pt, &q);
1076            let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
1077            for id in exact {
1078                assert!(
1079                    bloom.contains(&id),
1080                    "Bloom tier dropped a true match (false negative) for {q:?}"
1081                );
1082            }
1083        }
1084    }
1085
1086    /// End-to-end: an index forced onto the Bloom tier must still surface every
1087    /// file that actually contains the literal (recall parity with a full scan).
1088    #[test]
1089    fn bloom_tier_end_to_end_recall() {
1090        let samples = [
1091            (
1092                "auth_login.rs",
1093                "fn authenticate(user) {}\nlet token = mint();\n",
1094            ),
1095            (
1096                "auth_session.rs",
1097                "struct Session;\n// authenticate again here\n",
1098            ),
1099            ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
1100            (
1101                "ui_button.tsx",
1102                "export const Button = () => authenticate;\n",
1103            ),
1104            ("readme.md", "This project uses authenticate flows.\n"),
1105            ("unrelated.rs", "fn helper() { let v = 1; }\n"),
1106        ];
1107        let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
1108        let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
1109
1110        let idx = SearchIndex {
1111            files,
1112            narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
1113            respect_gitignore: true,
1114            allow_secret_paths: false,
1115            signature: 0, // freshness is not exercised by candidate_paths
1116        };
1117        assert!(
1118            matches!(idx.narrowing, Narrowing::Blooms(_)),
1119            "test must exercise the Bloom tier"
1120        );
1121
1122        for query in ["authenticate", "Session", "retries", "token"] {
1123            let cands: HashSet<String> = idx
1124                .candidate_paths(query, &[], std::path::Path::new(""))
1125                .into_paths()
1126                .iter()
1127                .map(|p| p.to_string_lossy().to_string())
1128                .collect();
1129            for (rel, content) in samples {
1130                if content.contains(query) {
1131                    assert!(
1132                        cands.contains(rel),
1133                        "Bloom tier dropped real match {rel} for query {query:?}"
1134                    );
1135                }
1136            }
1137        }
1138    }
1139
1140    /// A scoped override of `LEAN_CTX_DATA_DIR`, restored on drop, so the
1141    /// cross-process lock files land in an isolated temp dir during tests.
1142    struct DataDirGuard {
1143        prev: Option<String>,
1144    }
1145    impl DataDirGuard {
1146        fn set(path: &std::path::Path) -> Self {
1147            let prev = std::env::var("LEAN_CTX_DATA_DIR").ok();
1148            crate::test_env::set_var("LEAN_CTX_DATA_DIR", path);
1149            Self { prev }
1150        }
1151    }
1152    impl Drop for DataDirGuard {
1153        fn drop(&mut self) {
1154            match self.prev.as_deref() {
1155                Some(v) => crate::test_env::set_var("LEAN_CTX_DATA_DIR", v),
1156                None => crate::test_env::remove_var("LEAN_CTX_DATA_DIR"),
1157            }
1158        }
1159    }
1160
1161    #[test]
1162    fn search_index_lock_name_is_per_repo_and_distinct() {
1163        let a = search_index_lock_name("/tmp/repo-a");
1164        let b = search_index_lock_name("/tmp/repo-b");
1165        assert!(a.starts_with("search-idx-"), "unexpected lock name: {a}");
1166        assert_ne!(a, b, "lock name must be per-repo");
1167        assert_eq!(a, search_index_lock_name("/tmp/repo-a"), "stable per repo");
1168        // Must not collide with the graph/bm25 locks for the same repo, or the
1169        // three indexers would needlessly serialize against one another.
1170        let h = &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8];
1171        assert_ne!(
1172            a,
1173            format!("graph-idx-{h}"),
1174            "must not collide with graph lock"
1175        );
1176        assert_ne!(
1177            a,
1178            format!("bm25-idx-{h}"),
1179            "must not collide with bm25 lock"
1180        );
1181    }
1182
1183    #[test]
1184    fn build_guarded_builds_when_uncontended() {
1185        let _env = crate::core::data_dir::test_env_lock();
1186        let data = tempfile::tempdir().unwrap();
1187        let _guard = DataDirGuard::set(data.path());
1188
1189        let dir = corpus();
1190        let root = dir.path().to_string_lossy().to_string();
1191        // Seed the in-flight flag the way `ensure_background` does before spawn.
1192        {
1193            let mut map = cache()
1194                .lock()
1195                .unwrap_or_else(std::sync::PoisonError::into_inner);
1196            map.insert(
1197                root.clone(),
1198                CacheEntry {
1199                    index: None,
1200                    building: true,
1201                    last_verified: None,
1202                },
1203            );
1204        }
1205        assert_eq!(
1206            build_guarded(&root, true, false),
1207            BuildOutcome::Built,
1208            "an uncontended root must build"
1209        );
1210        let map = cache()
1211            .lock()
1212            .unwrap_or_else(std::sync::PoisonError::into_inner);
1213        let entry = map.get(&root).expect("entry present");
1214        assert!(!entry.building, "building flag must clear after build");
1215        assert!(entry.index.is_some(), "index must be installed after build");
1216    }
1217
1218    #[test]
1219    fn build_guarded_defers_when_another_process_holds_the_lock() {
1220        let _env = crate::core::data_dir::test_env_lock();
1221        let data = tempfile::tempdir().unwrap();
1222        let _guard = DataDirGuard::set(data.path());
1223
1224        let dir = corpus();
1225        let root = dir.path().to_string_lossy().to_string();
1226        // Pre-hold the cross-process lock with *this* (alive) PID and a fresh
1227        // mtime, so neither the dead-owner nor the staleness reclaim can take it
1228        // — exactly the "another session is already building" state from #460.
1229        let lock_path = data
1230            .path()
1231            .join(format!(".{}.lock", search_index_lock_name(&root)));
1232        std::fs::write(&lock_path, format!("{}\n", std::process::id())).unwrap();
1233
1234        {
1235            let mut map = cache()
1236                .lock()
1237                .unwrap_or_else(std::sync::PoisonError::into_inner);
1238            map.insert(
1239                root.clone(),
1240                CacheEntry {
1241                    index: None,
1242                    building: true,
1243                    last_verified: None,
1244                },
1245            );
1246        }
1247        assert_eq!(
1248            build_guarded(&root, true, false),
1249            BuildOutcome::Deferred,
1250            "a contended root must defer the proactive pre-warm"
1251        );
1252        let map = cache()
1253            .lock()
1254            .unwrap_or_else(std::sync::PoisonError::into_inner);
1255        let entry = map.get(&root).expect("entry present");
1256        assert!(
1257            !entry.building,
1258            "deferred build must clear the in-flight flag so a later nudge retries"
1259        );
1260        assert!(
1261            entry.index.is_none(),
1262            "deferred build must not run a second walk / install an index"
1263        );
1264    }
1265
1266    #[test]
1267    fn corpus_signature_matches_a_freshly_built_index() {
1268        let dir = corpus();
1269        let root = dir.path().to_string_lossy().to_string();
1270        let idx = SearchIndex::build(&root, true, false).expect("index builds");
1271        let sig = corpus_signature(&root, true, false).expect("signature computes");
1272        assert_eq!(
1273            idx.signature, sig,
1274            "a freshly built index must match the live corpus signature, or it \
1275             would be treated as permanently stale and never served"
1276        );
1277        // Re-derivation is deterministic for an unchanged corpus.
1278        assert_eq!(sig, corpus_signature(&root, true, false).unwrap());
1279    }
1280
1281    #[test]
1282    fn corpus_signature_changes_on_edit_add_and_delete() {
1283        let dir = corpus();
1284        let root = dir.path().to_string_lossy().to_string();
1285        let base = corpus_signature(&root, true, false).unwrap();
1286
1287        std::fs::write(
1288            dir.path().join("a.rs"),
1289            "fn handler() {}\nlet x = 1;\nlet y = 2;\n",
1290        )
1291        .unwrap();
1292        let after_edit = corpus_signature(&root, true, false).unwrap();
1293        assert_ne!(
1294            base, after_edit,
1295            "an in-place edit must change the signature"
1296        );
1297
1298        std::fs::write(dir.path().join("d.rs"), "fn fresh() {}\n").unwrap();
1299        let after_add = corpus_signature(&root, true, false).unwrap();
1300        assert_ne!(
1301            after_edit, after_add,
1302            "adding a file must change the signature"
1303        );
1304
1305        std::fs::remove_file(dir.path().join("b.rs")).unwrap();
1306        let after_delete = corpus_signature(&root, true, false).unwrap();
1307        assert_ne!(
1308            after_add, after_delete,
1309            "deleting a file must change the signature"
1310        );
1311    }
1312
1313    #[test]
1314    fn get_fresh_serves_unchanged_then_refuses_after_edit() {
1315        let _env = crate::core::data_dir::test_env_lock();
1316        let data = tempfile::tempdir().unwrap();
1317        let _guard = DataDirGuard::set(data.path());
1318        crate::test_env::remove_var("LEAN_CTX_DISABLE_SEARCH_INDEX");
1319        crate::test_env::remove_var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS");
1320
1321        let dir = corpus();
1322        let root = dir.path().to_string_lossy().to_string();
1323        assert!(warm_blocking(&root, true, false), "index warms");
1324
1325        // Unchanged corpus → the resident index is served.
1326        assert!(
1327            get_fresh(&root, true, false).is_some(),
1328            "an unchanged corpus must serve the resident index"
1329        );
1330
1331        // Native edit (size changes) → the now-stale index is refused so the
1332        // caller walks the live corpus instead of trusting outdated trigrams.
1333        std::fs::write(
1334            dir.path().join("a.rs"),
1335            "fn handler() {}\nlet x = 1;\nlet z = 9;\n",
1336        )
1337        .unwrap();
1338        assert!(
1339            get_fresh(&root, true, false).is_none(),
1340            "an edited corpus must refuse the stale resident index (#624)"
1341        );
1342    }
1343}