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 uses a short TTL with background rebuild, mirroring
24//!   [`crate::core::bm25_cache`]. A real fs watcher is Phase 5.
25
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::{Duration, Instant};
30
31use glob::Pattern;
32use ignore::WalkBuilder;
33
34use crate::tools::ctx_search::{is_binary_ext, is_generated_file, MAX_FILE_SIZE, MAX_WALK_DEPTH};
35
36/// Freshness window before a background rebuild is triggered. Matches the
37/// bounded-staleness model already used by the BM25 cache.
38const TTL: Duration = Duration::from_secs(15);
39
40/// Upper bound on indexed files; larger trees fall back to the walk path.
41const MAX_FILES: usize = 200_000;
42
43/// Posting-entry budget (`file_id` occurrences across all trigrams). Up to this
44/// many entries we keep exact inverted posting lists (fastest, sparse lookups).
45/// Beyond it we switch to the per-file Bloom tier instead of giving up — see
46/// [`Narrowing`]. ~4 bytes each → ~48 MB before the switch.
47const MAX_POSTING_ENTRIES: usize = 12_000_000;
48
49/// Hard ceiling on total trigram entries collected during a build. Past this we
50/// abandon indexing (walk fallback) to avoid pathological memory use even with
51/// the compact Bloom tier.
52const MAX_TOTAL_ENTRIES: usize = 48_000_000;
53
54/// Bloom tuning: bits per distinct trigram and number of hash probes. ~12 bits
55/// with k=7 keeps the false-positive rate well under 1% — and a false positive
56/// only costs one extra regex-verified file read (never a missed match).
57const BLOOM_BITS_PER_ITEM: usize = 12;
58const BLOOM_K: usize = 7;
59/// Per-file Bloom size clamp (in bits): 64 bits min, 1 Mi bits (128 KiB) max.
60const BLOOM_MIN_BITS: usize = 64;
61const BLOOM_MAX_BITS: usize = 1 << 20;
62
63/// A trigram is indexable only if all three bytes are `[A-Za-z0-9_]`.
64fn is_word_byte(b: u8) -> bool {
65    b.is_ascii_alphanumeric() || b == b'_'
66}
67
68fn pack(b0: u8, b1: u8, b2: u8) -> u32 {
69    (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2)
70}
71
72/// How candidate files are narrowed for a literal query. Two tiers, chosen by
73/// corpus size, both providing a *superset* of true matches (zero false
74/// negatives) which `ctx_search` then regex-verifies:
75/// - `Postings`: exact inverted lists `trigram → sorted file ids`. Fast, sparse
76///   lookups; used while total entries fit [`MAX_POSTING_ENTRIES`].
77/// - `Blooms`: one compact per-file Bloom filter of the file's trigrams. ~3×
78///   smaller than postings, so monorepos that would otherwise blow the posting
79///   budget still get index-narrowing instead of a full directory walk.
80enum Narrowing {
81    Postings(HashMap<u32, Vec<u32>>),
82    Blooms(Vec<FileBloom>),
83}
84
85/// A per-file Bloom filter over the file's word-trigrams. No false negatives:
86/// if any probed bit for a trigram is unset, the file provably lacks it.
87struct FileBloom {
88    /// Bit storage; the filter width `m = bits.len() * 64` is a power of two.
89    bits: Vec<u64>,
90}
91
92/// 64-bit avalanche mix (splitmix64 finalizer) — spreads a packed trigram into
93/// a well-distributed hash for double-probing.
94#[inline]
95fn mix64(mut x: u64) -> u64 {
96    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
97    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
98    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
99    x ^ (x >> 31)
100}
101
102impl FileBloom {
103    fn with_capacity(distinct_trigrams: usize) -> Self {
104        let target = distinct_trigrams
105            .saturating_mul(BLOOM_BITS_PER_ITEM)
106            .next_power_of_two()
107            .clamp(BLOOM_MIN_BITS, BLOOM_MAX_BITS);
108        FileBloom {
109            bits: vec![0u64; target / 64],
110        }
111    }
112
113    #[inline]
114    fn m_bits(&self) -> usize {
115        self.bits.len() * 64
116    }
117
118    /// Double hashing: `p_i = h1 + i·h2 (mod m)` with `m` a power of two.
119    #[inline]
120    fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
121        let m = self.m_bits();
122        let mask = m - 1; // m is a power of two
123        let h = mix64(u64::from(trigram));
124        let h1 = (h & 0xFFFF_FFFF) as usize;
125        let h2 = ((h >> 32) as usize) | 1; // odd step → full-period probing
126        (0..BLOOM_K).map(move |i| h1.wrapping_add(i.wrapping_mul(h2)) & mask)
127    }
128
129    fn insert(&mut self, trigram: u32) {
130        for p in self.probes(trigram).collect::<Vec<_>>() {
131            self.bits[p / 64] |= 1u64 << (p % 64);
132        }
133    }
134
135    fn maybe_contains(&self, trigram: u32) -> bool {
136        self.probes(trigram)
137            .all(|p| self.bits[p / 64] & (1u64 << (p % 64)) != 0)
138    }
139}
140
141/// RAM-resident trigram index over one project root.
142pub struct SearchIndex {
143    files: Vec<PathBuf>,
144    /// Candidate-narrowing structure (exact postings or compact per-file Bloom).
145    narrowing: Narrowing,
146    respect_gitignore: bool,
147    allow_secret_paths: bool,
148    built_at: Instant,
149}
150
151impl SearchIndex {
152    /// Build the index by walking `root` with the exact same config and filters
153    /// as `ctx_search`, so the searchable file universe is identical.
154    pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
155        let root_path = Path::new(root);
156        if !root_path.exists() {
157            return None;
158        }
159        // Never auto-index a broad/unsafe root (HOME, filesystem root, a dir with
160        // dozens of unrelated subtrees). This mirrors the graph/BM25 guard and
161        // stops a background build from walking the whole home directory — which
162        // on Windows would hydrate OneDrive placeholders (#363).
163        if !crate::core::graph_index::is_safe_scan_root_public(root) {
164            return None;
165        }
166
167        let walker = WalkBuilder::new(root_path)
168            .hidden(true)
169            .max_depth(Some(MAX_WALK_DEPTH))
170            .git_ignore(respect_gitignore)
171            .git_global(respect_gitignore)
172            .git_exclude(respect_gitignore)
173            .require_git(false)
174            .filter_entry(crate::core::walk_filter::keep_entry)
175            .build();
176
177        let mut files: Vec<PathBuf> = Vec::new();
178        // Per-file sorted, deduped trigrams. Same memory as the posting lists
179        // would be, but grouped by file so we can materialise *either* tier
180        // afterwards without a second pass over the corpus.
181        let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
182        let mut total_entries: usize = 0;
183        let mut scratch: HashSet<u32> = HashSet::new();
184
185        for entry in walker.filter_map(std::result::Result::ok) {
186            if entry.file_type().is_none_or(|ft| ft.is_dir()) {
187                continue;
188            }
189            if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
190                continue;
191            }
192            let path = entry.path();
193            if is_binary_ext(path) || is_generated_file(path) {
194                continue;
195            }
196            if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
197                continue;
198            }
199            // Only index regular files within the size budget. A FIFO/socket/
200            // device node would block the `read_to_string` below forever (#336),
201            // hanging the background build and starving the fast path. `metadata`
202            // (stat) never opens the file, so it is safe on special files.
203            let state = match std::fs::metadata(path) {
204                Ok(meta) if !meta.file_type().is_file() => continue,
205                Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
206                Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
207                Err(_) => continue,
208            };
209            // Read the corpus exactly once (issue #148): reuse a fresh cached
210            // copy if a prior `ctx_search`/build already read this file, else
211            // read it now and publish it so the upcoming `ctx_search` verify
212            // pass is an in-memory hit instead of a second disk read. Mirrors
213            // ctx_search: a non-UTF-8 file is never searchable, so it is skipped.
214            let content: std::sync::Arc<str> = if let Some(cached) =
215                state.and_then(|s| crate::core::content_cache::get(path, s))
216            {
217                cached
218            } else {
219                let Ok(text) = std::fs::read_to_string(path) else {
220                    continue;
221                };
222                let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
223                if let Some(s) = state {
224                    crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
225                }
226                arc
227            };
228
229            if files.len() >= MAX_FILES {
230                return None; // too large even for the Bloom tier — use the walk
231            }
232
233            scratch.clear();
234            let bytes = content.as_bytes();
235            if bytes.len() >= 3 {
236                for w in bytes.windows(3) {
237                    if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
238                        scratch.insert(pack(w[0], w[1], w[2]));
239                    }
240                }
241            }
242            total_entries += scratch.len();
243            if total_entries > MAX_TOTAL_ENTRIES {
244                return None; // memory guard — fall back to walk
245            }
246            let mut tris: Vec<u32> = scratch.iter().copied().collect();
247            tris.sort_unstable();
248            files.push(path.to_path_buf());
249            per_file_trigrams.push(tris);
250        }
251
252        let narrowing = build_narrowing(&per_file_trigrams, total_entries);
253
254        Some(Self {
255            files,
256            narrowing,
257            respect_gitignore,
258            allow_secret_paths,
259            built_at: Instant::now(),
260        })
261    }
262
263    fn is_fresh(&self) -> bool {
264        self.built_at.elapsed() < TTL
265    }
266
267    fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
268        self.respect_gitignore == respect_gitignore && self.allow_secret_paths == allow_secret_paths
269    }
270
271    /// Candidate files for `pattern`, filtered by the `include` glob (matched
272    /// against each file's path relative to `root`). `None` means "no safe
273    /// narrowing possible" — the caller should scan the full file list.
274    ///
275    /// Narrowing is applied only for pure `[A-Za-z0-9_]` literals of length ≥ 3.
276    /// For such a literal every match contains it on a single line, hence the
277    /// file contains all of its consecutive trigrams: intersecting their
278    /// posting lists yields a *superset* of matching files (zero false
279    /// negatives), which the caller then regex-verifies.
280    pub fn candidate_paths(
281        &self,
282        pattern: &str,
283        includes: &[Pattern],
284        root: &Path,
285    ) -> CandidateSet {
286        if let Some(ids) = self.literal_candidates(pattern) {
287            let paths = ids
288                .into_iter()
289                .map(|id| self.files[id as usize].clone())
290                .filter(|p| glob_matches(p, includes, root))
291                .collect();
292            CandidateSet::Narrowed(paths)
293        } else {
294            let paths = self
295                .files
296                .iter()
297                .filter(|p| glob_matches(p, includes, root))
298                .cloned()
299                .collect();
300            CandidateSet::FullList(paths)
301        }
302    }
303
304    /// Returns candidate file ids for a pure-literal query, or `None` if the
305    /// query is not a trigram-narrowable pure `[A-Za-z0-9_]` literal. Both tiers
306    /// return a *superset* of true matches (zero false negatives).
307    fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
308        let bytes = pattern.as_bytes();
309        if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
310            return None;
311        }
312        // Distinct trigrams of the literal.
313        let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
314        tris.sort_unstable();
315        tris.dedup();
316
317        match &self.narrowing {
318            Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
319            Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
320        }
321    }
322
323    /// Exact-tier: intersect the posting lists of every required trigram
324    /// (smallest first for a cheap intersection).
325    fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
326        let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
327        for &tri in tris {
328            match trigrams.get(&tri) {
329                // A required trigram is absent → provably no match anywhere.
330                None => return Vec::new(),
331                Some(list) => lists.push(list),
332            }
333        }
334        lists.sort_by_key(|l| l.len());
335
336        let mut acc: Vec<u32> = lists[0].clone();
337        for list in &lists[1..] {
338            acc = intersect_sorted(&acc, list);
339            if acc.is_empty() {
340                break;
341            }
342        }
343        acc
344    }
345
346    /// Bloom-tier: a file is a candidate iff its Bloom filter may contain every
347    /// required trigram. No false negatives (an unset probe bit ⇒ the trigram is
348    /// provably absent), so the result is still a superset of true matches.
349    fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
350        let mut out = Vec::new();
351        for (fid, bloom) in blooms.iter().enumerate() {
352            if tris.iter().all(|&t| bloom.maybe_contains(t)) {
353                out.push(fid as u32);
354            }
355        }
356        out
357    }
358}
359
360/// Materialise the appropriate narrowing tier for a freshly walked corpus.
361fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
362    if total_entries <= MAX_POSTING_ENTRIES {
363        let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
364        for (fid, tris) in per_file.iter().enumerate() {
365            for &t in tris {
366                // file ids are appended in ascending order ⇒ lists stay sorted.
367                trigrams.entry(t).or_default().push(fid as u32);
368            }
369        }
370        Narrowing::Postings(trigrams)
371    } else {
372        let blooms = per_file
373            .iter()
374            .map(|tris| {
375                let mut b = FileBloom::with_capacity(tris.len());
376                for &t in tris {
377                    b.insert(t);
378                }
379                b
380            })
381            .collect();
382        Narrowing::Blooms(blooms)
383    }
384}
385
386/// Result of [`SearchIndex::candidate_paths`].
387pub enum CandidateSet {
388    /// Trigram-narrowed candidate files (a superset of real matches).
389    Narrowed(Vec<PathBuf>),
390    /// No safe narrowing — the full cached file list (still skips the walk).
391    FullList(Vec<PathBuf>),
392}
393
394impl CandidateSet {
395    pub fn into_paths(self) -> Vec<PathBuf> {
396        match self {
397            CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
398        }
399    }
400}
401
402/// True when `path` matches *any* of the `includes` globs (relative to `root`),
403/// or when there is no filter (`includes` empty).
404fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
405    if includes.is_empty() {
406        return true;
407    }
408    let rel = path.strip_prefix(root).unwrap_or(path);
409    let rel_str = rel.to_string_lossy();
410    includes.iter().any(|p| p.matches(&rel_str))
411}
412
413/// Intersection of two ascending, deduped `u32` slices.
414fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
415    let mut out = Vec::new();
416    let (mut i, mut j) = (0, 0);
417    while i < a.len() && j < b.len() {
418        match a[i].cmp(&b[j]) {
419            std::cmp::Ordering::Less => i += 1,
420            std::cmp::Ordering::Greater => j += 1,
421            std::cmp::Ordering::Equal => {
422                out.push(a[i]);
423                i += 1;
424                j += 1;
425            }
426        }
427    }
428    out
429}
430
431// ---------------------------------------------------------------------------
432// Resident cache (one index per project root) with background (re)build.
433// ---------------------------------------------------------------------------
434
435struct CacheEntry {
436    index: Option<Arc<SearchIndex>>,
437    building: bool,
438}
439
440static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
441
442fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
443    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
444}
445
446/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
447/// everywhere (debugging / A-B measurement / opt-out).
448fn index_disabled() -> bool {
449    std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
450        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
451}
452
453/// Returns a fresh resident index for `root` if one is available for the given
454/// config, otherwise spawns a background (re)build and returns `None` so the
455/// caller uses the walk fallback for this call.
456pub fn get_fresh(
457    root: &str,
458    respect_gitignore: bool,
459    allow_secret_paths: bool,
460) -> Option<Arc<SearchIndex>> {
461    // Privileged "ignore gitignore" scans are rare and bypass the index.
462    if !respect_gitignore || index_disabled() {
463        return None;
464    }
465
466    let mut needs_build = false;
467    let result = {
468        let mut map = cache()
469            .lock()
470            .unwrap_or_else(std::sync::PoisonError::into_inner);
471        let entry = map.entry(root.to_string()).or_insert(CacheEntry {
472            index: None,
473            building: false,
474        });
475        match &entry.index {
476            Some(idx)
477                if idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh() =>
478            {
479                Some(Arc::clone(idx))
480            }
481            Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
482                // Stale but usable: serve it and refresh in the background.
483                needs_build = !entry.building;
484                if needs_build {
485                    entry.building = true;
486                }
487                Some(Arc::clone(idx))
488            }
489            _ => {
490                needs_build = !entry.building;
491                if needs_build {
492                    entry.building = true;
493                }
494                None
495            }
496        }
497    };
498
499    if needs_build {
500        spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
501    }
502    result
503}
504
505/// Ensure a resident index for `root` is built (or building) in the background.
506/// Safe to call repeatedly; deduped via the per-root `building` flag.
507pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
508    if !respect_gitignore || index_disabled() {
509        return;
510    }
511    let needs_build = {
512        let mut map = cache()
513            .lock()
514            .unwrap_or_else(std::sync::PoisonError::into_inner);
515        let entry = map.entry(root.to_string()).or_insert(CacheEntry {
516            index: None,
517            building: false,
518        });
519        let fresh = entry.index.as_ref().is_some_and(|idx| {
520            idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh()
521        });
522        if fresh || entry.building {
523            false
524        } else {
525            entry.building = true;
526            true
527        }
528    };
529    if needs_build {
530        spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
531    }
532}
533
534/// Build the index synchronously and install it in the resident cache.
535/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
536/// guaranteed-warm index. Respects the disable env var.
537pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
538    if !respect_gitignore || index_disabled() {
539        return false;
540    }
541    let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
542        return false;
543    };
544    let mut map = cache()
545        .lock()
546        .unwrap_or_else(std::sync::PoisonError::into_inner);
547    map.insert(
548        root.to_string(),
549        CacheEntry {
550            index: Some(Arc::new(idx)),
551            building: false,
552        },
553    );
554    true
555}
556
557fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
558    std::thread::spawn(move || {
559        let built = std::panic::catch_unwind(|| {
560            SearchIndex::build(&root, respect_gitignore, allow_secret_paths)
561        })
562        .ok()
563        .flatten();
564
565        let mut map = cache()
566            .lock()
567            .unwrap_or_else(std::sync::PoisonError::into_inner);
568        if let Some(entry) = map.get_mut(&root) {
569            entry.building = false;
570            if let Some(idx) = built {
571                entry.index = Some(Arc::new(idx));
572            }
573        }
574    });
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    fn corpus() -> tempfile::TempDir {
582        let dir = tempfile::tempdir().unwrap();
583        std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
584        std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
585        std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
586        dir
587    }
588
589    #[test]
590    fn build_refuses_to_index_home_directory() {
591        // Auto-indexing HOME would walk the entire home tree and, on Windows,
592        // hydrate every OneDrive placeholder (#363). The build must bail out.
593        if let Some(home) = dirs::home_dir() {
594            assert!(
595                SearchIndex::build(&home.to_string_lossy(), true, false).is_none(),
596                "search index must never auto-build over the home directory"
597            );
598        }
599    }
600
601    #[test]
602    fn narrows_to_files_containing_literal() {
603        let dir = corpus();
604        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
605        let cands = idx.candidate_paths("handler", &[], dir.path());
606        let paths = cands.into_paths();
607        // a.rs and c.txt contain "handler"; b.rs must be excluded.
608        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
609        assert!(paths.iter().any(|p| p.ends_with("c.txt")));
610        assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
611    }
612
613    #[test]
614    fn absent_trigram_yields_empty_candidates() {
615        let dir = corpus();
616        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
617        match idx.candidate_paths("zzzqqq", &[], dir.path()) {
618            CandidateSet::Narrowed(p) => assert!(p.is_empty()),
619            CandidateSet::FullList(_) => panic!("pure literal should narrow"),
620        }
621    }
622
623    #[test]
624    fn ext_filter_restricts_candidates() {
625        let dir = corpus();
626        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
627        let paths = idx
628            .candidate_paths(
629                "handler",
630                &[glob::Pattern::new("*.rs").unwrap()],
631                dir.path(),
632            )
633            .into_paths();
634        assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
635        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
636    }
637
638    #[test]
639    #[cfg(unix)]
640    fn build_skips_named_pipe_without_hanging() {
641        use std::sync::mpsc;
642        use std::time::Duration;
643        // #336: the background index build read every file, so a FIFO in the
644        // corpus blocked the build thread forever. It must be skipped while the
645        // regular files are still indexed, and the build must return.
646        let dir = corpus();
647        let fifo = dir.path().join("pipe.fifo");
648        let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
649        assert_eq!(
650            // SAFETY: `c` is a live CString providing a valid NUL-terminated
651            // path pointer for the duration of the call.
652            unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
653            0,
654            "mkfifo failed"
655        );
656
657        let root = dir.path().to_str().unwrap().to_string();
658        let (tx, rx) = mpsc::channel();
659        std::thread::spawn(move || {
660            let built = SearchIndex::build(&root, true, false);
661            let _ = tx.send(built.map(|idx| {
662                idx.candidate_paths("handler", &[], std::path::Path::new(&root))
663                    .into_paths()
664            }));
665        });
666        let paths = rx
667            .recv_timeout(Duration::from_secs(5))
668            .expect("SearchIndex::build hung on a FIFO (#336 regression)")
669            .expect("index should build");
670        assert!(paths.iter().any(|p| p.ends_with("a.rs")));
671        assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
672    }
673
674    #[test]
675    fn regex_query_falls_back_to_full_list() {
676        let dir = corpus();
677        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
678        match idx.candidate_paths("fn .*\\(\\)", &[], dir.path()) {
679            CandidateSet::FullList(p) => assert!(!p.is_empty()),
680            CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
681        }
682    }
683
684    #[test]
685    fn short_query_falls_back_to_full_list() {
686        let dir = corpus();
687        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
688        assert!(matches!(
689            idx.candidate_paths("fn", &[], dir.path()),
690            CandidateSet::FullList(_)
691        ));
692    }
693
694    /// The core correctness claim: trigram narrowing never drops a real match.
695    /// For each literal query, the set of `file:line` hits found by scanning only
696    /// the narrowed candidates must equal the set found by scanning every file.
697    #[test]
698    fn narrowing_has_identical_recall_to_full_scan() {
699        use regex::Regex;
700        use std::collections::BTreeSet;
701
702        let dir = tempfile::tempdir().unwrap();
703        // A spread of files; some contain the query tokens, most do not.
704        let samples = [
705            (
706                "auth/login.rs",
707                "fn authenticate(user) {}\nlet token = mint();\n",
708            ),
709            (
710                "auth/session.rs",
711                "struct Session;\n// authenticate again here\n",
712            ),
713            ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
714            (
715                "ui/button.tsx",
716                "export const Button = () => authenticate;\n",
717            ),
718            ("readme.md", "This project uses authenticate flows.\n"),
719            ("unrelated.rs", "fn helper() { let v = 1; }\n"),
720        ];
721        for (rel, content) in samples {
722            let p = dir.path().join(rel);
723            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
724            std::fs::write(p, content).unwrap();
725        }
726        let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
727
728        let full_scan = |pat: &str| -> BTreeSet<String> {
729            let re = Regex::new(pat).unwrap();
730            let mut hits = BTreeSet::new();
731            for (rel, content) in samples {
732                for (i, line) in content.lines().enumerate() {
733                    if re.is_match(line) {
734                        hits.insert(format!("{rel}:{}", i + 1));
735                    }
736                }
737            }
738            hits
739        };
740
741        for query in ["authenticate", "Session", "retries", "token"] {
742            let re = Regex::new(query).unwrap();
743            let candidates = idx.candidate_paths(query, &[], dir.path()).into_paths();
744            let mut narrowed = BTreeSet::new();
745            for path in &candidates {
746                let content = std::fs::read_to_string(path).unwrap();
747                let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
748                for (i, line) in content.lines().enumerate() {
749                    if re.is_match(line) {
750                        narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
751                    }
752                }
753            }
754            assert_eq!(
755                narrowed,
756                full_scan(query),
757                "recall mismatch for query {query:?}"
758            );
759        }
760    }
761
762    #[test]
763    fn intersect_sorted_basic() {
764        assert_eq!(
765            intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
766            vec![2, 3, 5]
767        );
768        assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
769    }
770
771    // ── Bloom tier ────────────────────────────────────────────────────────
772
773    fn trigrams_of(s: &str) -> Vec<u32> {
774        let mut set = HashSet::new();
775        let b = s.as_bytes();
776        if b.len() >= 3 {
777            for w in b.windows(3) {
778                if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
779                    set.insert(pack(w[0], w[1], w[2]));
780                }
781            }
782        }
783        let mut v: Vec<u32> = set.into_iter().collect();
784        v.sort_unstable();
785        v
786    }
787
788    #[test]
789    fn file_bloom_has_no_false_negatives() {
790        let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
791        let mut bloom = FileBloom::with_capacity(tris.len());
792        for &t in &tris {
793            bloom.insert(t);
794        }
795        // Every inserted trigram must be reported present (Bloom guarantee).
796        assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
797    }
798
799    /// Parity fuzz: the Bloom tier must return a *superset* of the exact posting
800    /// tier for every query (zero false negatives). False positives are allowed
801    /// (and verified away downstream), so we assert containment, not equality.
802    #[test]
803    fn bloom_tier_is_superset_of_postings_tier() {
804        // Deterministic synthetic corpus (LCG → reproducible).
805        let mut seed = 0x1234_5678_9abc_def0u64;
806        let mut rng = || {
807            seed = seed
808                .wrapping_mul(6364136223846793005)
809                .wrapping_add(1442695040888963407);
810            (seed >> 33) as u32
811        };
812        let mut per_file: Vec<Vec<u32>> = Vec::new();
813        for _ in 0..80 {
814            let n = 50 + (rng() % 250) as usize;
815            let mut s = HashSet::new();
816            for _ in 0..n {
817                s.insert(rng() & 0x00FF_FFFF);
818            }
819            let mut v: Vec<u32> = s.into_iter().collect();
820            v.sort_unstable();
821            per_file.push(v);
822        }
823        let total: usize = per_file.iter().map(Vec::len).sum();
824
825        let postings = build_narrowing(&per_file, total); // ≤ cap → postings
826        let blooms = build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1); // forced bloom
827        let (Narrowing::Postings(pt), Narrowing::Blooms(bl)) = (&postings, &blooms) else {
828            panic!("unexpected narrowing tiers");
829        };
830
831        // Queries drawn from real file trigrams (these MUST be found by both),
832        // plus a few that are unlikely to exist anywhere.
833        for f in &per_file {
834            if f.len() < 3 {
835                continue;
836            }
837            let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
838            let exact = SearchIndex::postings_intersect(pt, &q);
839            let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
840            for id in exact {
841                assert!(
842                    bloom.contains(&id),
843                    "Bloom tier dropped a true match (false negative) for {q:?}"
844                );
845            }
846        }
847    }
848
849    /// End-to-end: an index forced onto the Bloom tier must still surface every
850    /// file that actually contains the literal (recall parity with a full scan).
851    #[test]
852    fn bloom_tier_end_to_end_recall() {
853        let samples = [
854            (
855                "auth_login.rs",
856                "fn authenticate(user) {}\nlet token = mint();\n",
857            ),
858            (
859                "auth_session.rs",
860                "struct Session;\n// authenticate again here\n",
861            ),
862            ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
863            (
864                "ui_button.tsx",
865                "export const Button = () => authenticate;\n",
866            ),
867            ("readme.md", "This project uses authenticate flows.\n"),
868            ("unrelated.rs", "fn helper() { let v = 1; }\n"),
869        ];
870        let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
871        let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
872
873        let idx = SearchIndex {
874            files,
875            narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
876            respect_gitignore: true,
877            allow_secret_paths: false,
878            built_at: Instant::now(),
879        };
880        assert!(
881            matches!(idx.narrowing, Narrowing::Blooms(_)),
882            "test must exercise the Bloom tier"
883        );
884
885        for query in ["authenticate", "Session", "retries", "token"] {
886            let cands: HashSet<String> = idx
887                .candidate_paths(query, &[], std::path::Path::new(""))
888                .into_paths()
889                .iter()
890                .map(|p| p.to_string_lossy().to_string())
891                .collect();
892            for (rel, content) in samples {
893                if content.contains(query) {
894                    assert!(
895                        cands.contains(rel),
896                        "Bloom tier dropped real match {rel} for query {query:?}"
897                    );
898                }
899            }
900        }
901    }
902}