1use 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
36const TTL: Duration = Duration::from_secs(15);
39
40const MAX_FILES: usize = 200_000;
42
43const MAX_POSTING_ENTRIES: usize = 12_000_000;
48
49const MAX_TOTAL_ENTRIES: usize = 48_000_000;
53
54const BLOOM_BITS_PER_ITEM: usize = 12;
58const BLOOM_K: usize = 7;
59const BLOOM_MIN_BITS: usize = 64;
61const BLOOM_MAX_BITS: usize = 1 << 20;
62
63fn 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
72enum Narrowing {
81 Postings(HashMap<u32, Vec<u32>>),
82 Blooms(Vec<FileBloom>),
83}
84
85struct FileBloom {
88 bits: Vec<u64>,
90}
91
92#[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 #[inline]
120 fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
121 let m = self.m_bits();
122 let mask = m - 1; let h = mix64(u64::from(trigram));
124 let h1 = (h & 0xFFFF_FFFF) as usize;
125 let h2 = ((h >> 32) as usize) | 1; (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
141pub struct SearchIndex {
143 files: Vec<PathBuf>,
144 narrowing: Narrowing,
146 respect_gitignore: bool,
147 allow_secret_paths: bool,
148 built_at: Instant,
149}
150
151impl SearchIndex {
152 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 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 .filter_entry(crate::core::cloud_files::keep_entry)
174 .build();
175
176 let mut files: Vec<PathBuf> = Vec::new();
177 let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
181 let mut total_entries: usize = 0;
182 let mut scratch: HashSet<u32> = HashSet::new();
183
184 for entry in walker.filter_map(std::result::Result::ok) {
185 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
186 continue;
187 }
188 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
189 continue;
190 }
191 let path = entry.path();
192 if is_binary_ext(path) || is_generated_file(path) {
193 continue;
194 }
195 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
196 continue;
197 }
198 let state = match std::fs::metadata(path) {
203 Ok(meta) if !meta.file_type().is_file() => continue,
204 Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
205 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
206 Err(_) => continue,
207 };
208 let content: std::sync::Arc<str> = if let Some(cached) =
214 state.and_then(|s| crate::core::content_cache::get(path, s))
215 {
216 cached
217 } else {
218 let Ok(text) = std::fs::read_to_string(path) else {
219 continue;
220 };
221 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
222 if let Some(s) = state {
223 crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
224 }
225 arc
226 };
227
228 if files.len() >= MAX_FILES {
229 return None; }
231
232 scratch.clear();
233 let bytes = content.as_bytes();
234 if bytes.len() >= 3 {
235 for w in bytes.windows(3) {
236 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
237 scratch.insert(pack(w[0], w[1], w[2]));
238 }
239 }
240 }
241 total_entries += scratch.len();
242 if total_entries > MAX_TOTAL_ENTRIES {
243 return None; }
245 let mut tris: Vec<u32> = scratch.iter().copied().collect();
246 tris.sort_unstable();
247 files.push(path.to_path_buf());
248 per_file_trigrams.push(tris);
249 }
250
251 let narrowing = build_narrowing(&per_file_trigrams, total_entries);
252
253 Some(Self {
254 files,
255 narrowing,
256 respect_gitignore,
257 allow_secret_paths,
258 built_at: Instant::now(),
259 })
260 }
261
262 fn is_fresh(&self) -> bool {
263 self.built_at.elapsed() < TTL
264 }
265
266 fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
267 self.respect_gitignore == respect_gitignore && self.allow_secret_paths == allow_secret_paths
268 }
269
270 pub fn candidate_paths(
280 &self,
281 pattern: &str,
282 includes: &[Pattern],
283 root: &Path,
284 ) -> CandidateSet {
285 if let Some(ids) = self.literal_candidates(pattern) {
286 let paths = ids
287 .into_iter()
288 .map(|id| self.files[id as usize].clone())
289 .filter(|p| glob_matches(p, includes, root))
290 .collect();
291 CandidateSet::Narrowed(paths)
292 } else {
293 let paths = self
294 .files
295 .iter()
296 .filter(|p| glob_matches(p, includes, root))
297 .cloned()
298 .collect();
299 CandidateSet::FullList(paths)
300 }
301 }
302
303 fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
307 let bytes = pattern.as_bytes();
308 if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
309 return None;
310 }
311 let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
313 tris.sort_unstable();
314 tris.dedup();
315
316 match &self.narrowing {
317 Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
318 Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
319 }
320 }
321
322 fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
325 let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
326 for &tri in tris {
327 match trigrams.get(&tri) {
328 None => return Vec::new(),
330 Some(list) => lists.push(list),
331 }
332 }
333 lists.sort_by_key(|l| l.len());
334
335 let mut acc: Vec<u32> = lists[0].clone();
336 for list in &lists[1..] {
337 acc = intersect_sorted(&acc, list);
338 if acc.is_empty() {
339 break;
340 }
341 }
342 acc
343 }
344
345 fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
349 let mut out = Vec::new();
350 for (fid, bloom) in blooms.iter().enumerate() {
351 if tris.iter().all(|&t| bloom.maybe_contains(t)) {
352 out.push(fid as u32);
353 }
354 }
355 out
356 }
357}
358
359fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
361 if total_entries <= MAX_POSTING_ENTRIES {
362 let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
363 for (fid, tris) in per_file.iter().enumerate() {
364 for &t in tris {
365 trigrams.entry(t).or_default().push(fid as u32);
367 }
368 }
369 Narrowing::Postings(trigrams)
370 } else {
371 let blooms = per_file
372 .iter()
373 .map(|tris| {
374 let mut b = FileBloom::with_capacity(tris.len());
375 for &t in tris {
376 b.insert(t);
377 }
378 b
379 })
380 .collect();
381 Narrowing::Blooms(blooms)
382 }
383}
384
385pub enum CandidateSet {
387 Narrowed(Vec<PathBuf>),
389 FullList(Vec<PathBuf>),
391}
392
393impl CandidateSet {
394 pub fn into_paths(self) -> Vec<PathBuf> {
395 match self {
396 CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
397 }
398 }
399}
400
401fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
404 if includes.is_empty() {
405 return true;
406 }
407 let rel = path.strip_prefix(root).unwrap_or(path);
408 let rel_str = rel.to_string_lossy();
409 includes.iter().any(|p| p.matches(&rel_str))
410}
411
412fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
414 let mut out = Vec::new();
415 let (mut i, mut j) = (0, 0);
416 while i < a.len() && j < b.len() {
417 match a[i].cmp(&b[j]) {
418 std::cmp::Ordering::Less => i += 1,
419 std::cmp::Ordering::Greater => j += 1,
420 std::cmp::Ordering::Equal => {
421 out.push(a[i]);
422 i += 1;
423 j += 1;
424 }
425 }
426 }
427 out
428}
429
430struct CacheEntry {
435 index: Option<Arc<SearchIndex>>,
436 building: bool,
437}
438
439static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
440
441fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
442 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
443}
444
445fn index_disabled() -> bool {
448 std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
449 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
450}
451
452pub fn get_fresh(
456 root: &str,
457 respect_gitignore: bool,
458 allow_secret_paths: bool,
459) -> Option<Arc<SearchIndex>> {
460 if !respect_gitignore || index_disabled() {
462 return None;
463 }
464
465 let mut needs_build = false;
466 let result = {
467 let mut map = cache()
468 .lock()
469 .unwrap_or_else(std::sync::PoisonError::into_inner);
470 let entry = map.entry(root.to_string()).or_insert(CacheEntry {
471 index: None,
472 building: false,
473 });
474 match &entry.index {
475 Some(idx)
476 if idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh() =>
477 {
478 Some(Arc::clone(idx))
479 }
480 Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
481 needs_build = !entry.building;
483 if needs_build {
484 entry.building = true;
485 }
486 Some(Arc::clone(idx))
487 }
488 _ => {
489 needs_build = !entry.building;
490 if needs_build {
491 entry.building = true;
492 }
493 None
494 }
495 }
496 };
497
498 if needs_build {
499 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
500 }
501 result
502}
503
504pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
507 if !respect_gitignore || index_disabled() {
508 return;
509 }
510 let needs_build = {
511 let mut map = cache()
512 .lock()
513 .unwrap_or_else(std::sync::PoisonError::into_inner);
514 let entry = map.entry(root.to_string()).or_insert(CacheEntry {
515 index: None,
516 building: false,
517 });
518 let fresh = entry.index.as_ref().is_some_and(|idx| {
519 idx.config_matches(respect_gitignore, allow_secret_paths) && idx.is_fresh()
520 });
521 if fresh || entry.building {
522 false
523 } else {
524 entry.building = true;
525 true
526 }
527 };
528 if needs_build {
529 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
530 }
531}
532
533pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
537 if !respect_gitignore || index_disabled() {
538 return false;
539 }
540 let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
541 return false;
542 };
543 let mut map = cache()
544 .lock()
545 .unwrap_or_else(std::sync::PoisonError::into_inner);
546 map.insert(
547 root.to_string(),
548 CacheEntry {
549 index: Some(Arc::new(idx)),
550 building: false,
551 },
552 );
553 true
554}
555
556fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
557 std::thread::spawn(move || {
558 let built = std::panic::catch_unwind(|| {
559 SearchIndex::build(&root, respect_gitignore, allow_secret_paths)
560 })
561 .ok()
562 .flatten();
563
564 let mut map = cache()
565 .lock()
566 .unwrap_or_else(std::sync::PoisonError::into_inner);
567 if let Some(entry) = map.get_mut(&root) {
568 entry.building = false;
569 if let Some(idx) = built {
570 entry.index = Some(Arc::new(idx));
571 }
572 }
573 });
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn corpus() -> tempfile::TempDir {
581 let dir = tempfile::tempdir().unwrap();
582 std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
583 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
584 std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
585 dir
586 }
587
588 #[test]
589 fn build_refuses_to_index_home_directory() {
590 if let Some(home) = dirs::home_dir() {
593 assert!(
594 SearchIndex::build(&home.to_string_lossy(), true, false).is_none(),
595 "search index must never auto-build over the home directory"
596 );
597 }
598 }
599
600 #[test]
601 fn narrows_to_files_containing_literal() {
602 let dir = corpus();
603 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
604 let cands = idx.candidate_paths("handler", &[], dir.path());
605 let paths = cands.into_paths();
606 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
608 assert!(paths.iter().any(|p| p.ends_with("c.txt")));
609 assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
610 }
611
612 #[test]
613 fn absent_trigram_yields_empty_candidates() {
614 let dir = corpus();
615 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
616 match idx.candidate_paths("zzzqqq", &[], dir.path()) {
617 CandidateSet::Narrowed(p) => assert!(p.is_empty()),
618 CandidateSet::FullList(_) => panic!("pure literal should narrow"),
619 }
620 }
621
622 #[test]
623 fn ext_filter_restricts_candidates() {
624 let dir = corpus();
625 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
626 let paths = idx
627 .candidate_paths(
628 "handler",
629 &[glob::Pattern::new("*.rs").unwrap()],
630 dir.path(),
631 )
632 .into_paths();
633 assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
634 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
635 }
636
637 #[test]
638 #[cfg(unix)]
639 fn build_skips_named_pipe_without_hanging() {
640 use std::sync::mpsc;
641 use std::time::Duration;
642 let dir = corpus();
646 let fifo = dir.path().join("pipe.fifo");
647 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
648 assert_eq!(
649 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
652 0,
653 "mkfifo failed"
654 );
655
656 let root = dir.path().to_str().unwrap().to_string();
657 let (tx, rx) = mpsc::channel();
658 std::thread::spawn(move || {
659 let built = SearchIndex::build(&root, true, false);
660 let _ = tx.send(built.map(|idx| {
661 idx.candidate_paths("handler", &[], std::path::Path::new(&root))
662 .into_paths()
663 }));
664 });
665 let paths = rx
666 .recv_timeout(Duration::from_secs(5))
667 .expect("SearchIndex::build hung on a FIFO (#336 regression)")
668 .expect("index should build");
669 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
670 assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
671 }
672
673 #[test]
674 fn regex_query_falls_back_to_full_list() {
675 let dir = corpus();
676 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
677 match idx.candidate_paths("fn .*\\(\\)", &[], dir.path()) {
678 CandidateSet::FullList(p) => assert!(!p.is_empty()),
679 CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
680 }
681 }
682
683 #[test]
684 fn short_query_falls_back_to_full_list() {
685 let dir = corpus();
686 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
687 assert!(matches!(
688 idx.candidate_paths("fn", &[], dir.path()),
689 CandidateSet::FullList(_)
690 ));
691 }
692
693 #[test]
697 fn narrowing_has_identical_recall_to_full_scan() {
698 use regex::Regex;
699 use std::collections::BTreeSet;
700
701 let dir = tempfile::tempdir().unwrap();
702 let samples = [
704 (
705 "auth/login.rs",
706 "fn authenticate(user) {}\nlet token = mint();\n",
707 ),
708 (
709 "auth/session.rs",
710 "struct Session;\n// authenticate again here\n",
711 ),
712 ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
713 (
714 "ui/button.tsx",
715 "export const Button = () => authenticate;\n",
716 ),
717 ("readme.md", "This project uses authenticate flows.\n"),
718 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
719 ];
720 for (rel, content) in samples {
721 let p = dir.path().join(rel);
722 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
723 std::fs::write(p, content).unwrap();
724 }
725 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
726
727 let full_scan = |pat: &str| -> BTreeSet<String> {
728 let re = Regex::new(pat).unwrap();
729 let mut hits = BTreeSet::new();
730 for (rel, content) in samples {
731 for (i, line) in content.lines().enumerate() {
732 if re.is_match(line) {
733 hits.insert(format!("{rel}:{}", i + 1));
734 }
735 }
736 }
737 hits
738 };
739
740 for query in ["authenticate", "Session", "retries", "token"] {
741 let re = Regex::new(query).unwrap();
742 let candidates = idx.candidate_paths(query, &[], dir.path()).into_paths();
743 let mut narrowed = BTreeSet::new();
744 for path in &candidates {
745 let content = std::fs::read_to_string(path).unwrap();
746 let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
747 for (i, line) in content.lines().enumerate() {
748 if re.is_match(line) {
749 narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
750 }
751 }
752 }
753 assert_eq!(
754 narrowed,
755 full_scan(query),
756 "recall mismatch for query {query:?}"
757 );
758 }
759 }
760
761 #[test]
762 fn intersect_sorted_basic() {
763 assert_eq!(
764 intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
765 vec![2, 3, 5]
766 );
767 assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
768 }
769
770 fn trigrams_of(s: &str) -> Vec<u32> {
773 let mut set = HashSet::new();
774 let b = s.as_bytes();
775 if b.len() >= 3 {
776 for w in b.windows(3) {
777 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
778 set.insert(pack(w[0], w[1], w[2]));
779 }
780 }
781 }
782 let mut v: Vec<u32> = set.into_iter().collect();
783 v.sort_unstable();
784 v
785 }
786
787 #[test]
788 fn file_bloom_has_no_false_negatives() {
789 let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
790 let mut bloom = FileBloom::with_capacity(tris.len());
791 for &t in &tris {
792 bloom.insert(t);
793 }
794 assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
796 }
797
798 #[test]
802 fn bloom_tier_is_superset_of_postings_tier() {
803 let mut seed = 0x1234_5678_9abc_def0u64;
805 let mut rng = || {
806 seed = seed
807 .wrapping_mul(6364136223846793005)
808 .wrapping_add(1442695040888963407);
809 (seed >> 33) as u32
810 };
811 let mut per_file: Vec<Vec<u32>> = Vec::new();
812 for _ in 0..80 {
813 let n = 50 + (rng() % 250) as usize;
814 let mut s = HashSet::new();
815 for _ in 0..n {
816 s.insert(rng() & 0x00FF_FFFF);
817 }
818 let mut v: Vec<u32> = s.into_iter().collect();
819 v.sort_unstable();
820 per_file.push(v);
821 }
822 let total: usize = per_file.iter().map(Vec::len).sum();
823
824 let postings = build_narrowing(&per_file, total); let blooms = build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1); let (Narrowing::Postings(pt), Narrowing::Blooms(bl)) = (&postings, &blooms) else {
827 panic!("unexpected narrowing tiers");
828 };
829
830 for f in &per_file {
833 if f.len() < 3 {
834 continue;
835 }
836 let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
837 let exact = SearchIndex::postings_intersect(pt, &q);
838 let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
839 for id in exact {
840 assert!(
841 bloom.contains(&id),
842 "Bloom tier dropped a true match (false negative) for {q:?}"
843 );
844 }
845 }
846 }
847
848 #[test]
851 fn bloom_tier_end_to_end_recall() {
852 let samples = [
853 (
854 "auth_login.rs",
855 "fn authenticate(user) {}\nlet token = mint();\n",
856 ),
857 (
858 "auth_session.rs",
859 "struct Session;\n// authenticate again here\n",
860 ),
861 ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
862 (
863 "ui_button.tsx",
864 "export const Button = () => authenticate;\n",
865 ),
866 ("readme.md", "This project uses authenticate flows.\n"),
867 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
868 ];
869 let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
870 let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
871
872 let idx = SearchIndex {
873 files,
874 narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
875 respect_gitignore: true,
876 allow_secret_paths: false,
877 built_at: Instant::now(),
878 };
879 assert!(
880 matches!(idx.narrowing, Narrowing::Blooms(_)),
881 "test must exercise the Bloom tier"
882 );
883
884 for query in ["authenticate", "Session", "retries", "token"] {
885 let cands: HashSet<String> = idx
886 .candidate_paths(query, &[], std::path::Path::new(""))
887 .into_paths()
888 .iter()
889 .map(|p| p.to_string_lossy().to_string())
890 .collect();
891 for (rel, content) in samples {
892 if content.contains(query) {
893 assert!(
894 cands.contains(rel),
895 "Bloom tier dropped real match {rel} for query {query:?}"
896 );
897 }
898 }
899 }
900 }
901}