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::{MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file};
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 .require_git(false)
174 .filter_entry(crate::core::walk_filter::keep_entry)
175 .build();
176
177 let mut files: Vec<PathBuf> = Vec::new();
178 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 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 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; }
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; }
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 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 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 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 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 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 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
360fn 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 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
386pub enum CandidateSet {
388 Narrowed(Vec<PathBuf>),
390 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
402fn 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
413fn 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
431struct 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
446fn 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
453pub fn get_fresh(
457 root: &str,
458 respect_gitignore: bool,
459 allow_secret_paths: bool,
460) -> Option<Arc<SearchIndex>> {
461 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 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
505pub 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
534pub 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 search_index_lock_name(root: &str) -> String {
562 format!(
563 "search-idx-{}",
564 &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
565 )
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571enum BuildOutcome {
572 Built,
573 Deferred,
574}
575
576fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
587 let lock = crate::core::startup_guard::try_acquire_lock(
588 &search_index_lock_name(root),
589 Duration::from_millis(200),
590 Duration::from_mins(3),
591 );
592 if lock.is_none() {
593 let mut map = cache()
596 .lock()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 if let Some(entry) = map.get_mut(root) {
599 entry.building = false;
600 }
601 return BuildOutcome::Deferred;
602 }
603
604 let built = std::panic::catch_unwind(|| {
605 SearchIndex::build(root, respect_gitignore, allow_secret_paths)
606 })
607 .ok()
608 .flatten();
609
610 let mut map = cache()
611 .lock()
612 .unwrap_or_else(std::sync::PoisonError::into_inner);
613 if let Some(entry) = map.get_mut(root) {
614 entry.building = false;
615 if let Some(idx) = built {
616 entry.index = Some(Arc::new(idx));
617 }
618 }
619 drop(lock);
621 BuildOutcome::Built
622}
623
624fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
625 std::thread::spawn(move || {
626 let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
627 });
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 fn corpus() -> tempfile::TempDir {
635 let dir = tempfile::tempdir().unwrap();
636 std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
637 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
638 std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
639 dir
640 }
641
642 #[test]
643 fn build_refuses_to_index_home_directory() {
644 if let Some(home) = dirs::home_dir() {
647 assert!(
648 SearchIndex::build(&home.to_string_lossy(), true, false).is_none(),
649 "search index must never auto-build over the home directory"
650 );
651 }
652 }
653
654 #[test]
655 fn narrows_to_files_containing_literal() {
656 let dir = corpus();
657 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
658 let cands = idx.candidate_paths("handler", &[], dir.path());
659 let paths = cands.into_paths();
660 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
662 assert!(paths.iter().any(|p| p.ends_with("c.txt")));
663 assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
664 }
665
666 #[test]
667 fn absent_trigram_yields_empty_candidates() {
668 let dir = corpus();
669 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
670 match idx.candidate_paths("zzzqqq", &[], dir.path()) {
671 CandidateSet::Narrowed(p) => assert!(p.is_empty()),
672 CandidateSet::FullList(_) => panic!("pure literal should narrow"),
673 }
674 }
675
676 #[test]
677 fn ext_filter_restricts_candidates() {
678 let dir = corpus();
679 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
680 let paths = idx
681 .candidate_paths(
682 "handler",
683 &[glob::Pattern::new("*.rs").unwrap()],
684 dir.path(),
685 )
686 .into_paths();
687 assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
688 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
689 }
690
691 #[test]
692 #[cfg(unix)]
693 fn build_skips_named_pipe_without_hanging() {
694 use std::sync::mpsc;
695 use std::time::Duration;
696 let dir = corpus();
700 let fifo = dir.path().join("pipe.fifo");
701 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
702 assert_eq!(
703 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
706 0,
707 "mkfifo failed"
708 );
709
710 let root = dir.path().to_str().unwrap().to_string();
711 let (tx, rx) = mpsc::channel();
712 std::thread::spawn(move || {
713 let built = SearchIndex::build(&root, true, false);
714 let _ = tx.send(built.map(|idx| {
715 idx.candidate_paths("handler", &[], std::path::Path::new(&root))
716 .into_paths()
717 }));
718 });
719 let paths = rx
720 .recv_timeout(Duration::from_secs(5))
721 .expect("SearchIndex::build hung on a FIFO (#336 regression)")
722 .expect("index should build");
723 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
724 assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
725 }
726
727 #[test]
728 fn regex_query_falls_back_to_full_list() {
729 let dir = corpus();
730 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
731 match idx.candidate_paths("fn .*\\(\\)", &[], dir.path()) {
732 CandidateSet::FullList(p) => assert!(!p.is_empty()),
733 CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
734 }
735 }
736
737 #[test]
738 fn short_query_falls_back_to_full_list() {
739 let dir = corpus();
740 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
741 assert!(matches!(
742 idx.candidate_paths("fn", &[], dir.path()),
743 CandidateSet::FullList(_)
744 ));
745 }
746
747 #[test]
751 fn narrowing_has_identical_recall_to_full_scan() {
752 use regex::Regex;
753 use std::collections::BTreeSet;
754
755 let dir = tempfile::tempdir().unwrap();
756 let samples = [
758 (
759 "auth/login.rs",
760 "fn authenticate(user) {}\nlet token = mint();\n",
761 ),
762 (
763 "auth/session.rs",
764 "struct Session;\n// authenticate again here\n",
765 ),
766 ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
767 (
768 "ui/button.tsx",
769 "export const Button = () => authenticate;\n",
770 ),
771 ("readme.md", "This project uses authenticate flows.\n"),
772 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
773 ];
774 for (rel, content) in samples {
775 let p = dir.path().join(rel);
776 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
777 std::fs::write(p, content).unwrap();
778 }
779 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
780
781 let full_scan = |pat: &str| -> BTreeSet<String> {
782 let re = Regex::new(pat).unwrap();
783 let mut hits = BTreeSet::new();
784 for (rel, content) in samples {
785 for (i, line) in content.lines().enumerate() {
786 if re.is_match(line) {
787 hits.insert(format!("{rel}:{}", i + 1));
788 }
789 }
790 }
791 hits
792 };
793
794 for query in ["authenticate", "Session", "retries", "token"] {
795 let re = Regex::new(query).unwrap();
796 let candidates = idx.candidate_paths(query, &[], dir.path()).into_paths();
797 let mut narrowed = BTreeSet::new();
798 for path in &candidates {
799 let content = std::fs::read_to_string(path).unwrap();
800 let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
801 for (i, line) in content.lines().enumerate() {
802 if re.is_match(line) {
803 narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
804 }
805 }
806 }
807 assert_eq!(
808 narrowed,
809 full_scan(query),
810 "recall mismatch for query {query:?}"
811 );
812 }
813 }
814
815 #[test]
816 fn intersect_sorted_basic() {
817 assert_eq!(
818 intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
819 vec![2, 3, 5]
820 );
821 assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
822 }
823
824 fn trigrams_of(s: &str) -> Vec<u32> {
827 let mut set = HashSet::new();
828 let b = s.as_bytes();
829 if b.len() >= 3 {
830 for w in b.windows(3) {
831 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
832 set.insert(pack(w[0], w[1], w[2]));
833 }
834 }
835 }
836 let mut v: Vec<u32> = set.into_iter().collect();
837 v.sort_unstable();
838 v
839 }
840
841 #[test]
842 fn file_bloom_has_no_false_negatives() {
843 let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
844 let mut bloom = FileBloom::with_capacity(tris.len());
845 for &t in &tris {
846 bloom.insert(t);
847 }
848 assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
850 }
851
852 #[test]
856 fn bloom_tier_is_superset_of_postings_tier() {
857 let mut seed = 0x1234_5678_9abc_def0u64;
859 let mut rng = || {
860 seed = seed
861 .wrapping_mul(6364136223846793005)
862 .wrapping_add(1442695040888963407);
863 (seed >> 33) as u32
864 };
865 let mut per_file: Vec<Vec<u32>> = Vec::new();
866 for _ in 0..80 {
867 let n = 50 + (rng() % 250) as usize;
868 let mut s = HashSet::new();
869 for _ in 0..n {
870 s.insert(rng() & 0x00FF_FFFF);
871 }
872 let mut v: Vec<u32> = s.into_iter().collect();
873 v.sort_unstable();
874 per_file.push(v);
875 }
876 let total: usize = per_file.iter().map(Vec::len).sum();
877
878 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 {
881 panic!("unexpected narrowing tiers");
882 };
883
884 for f in &per_file {
887 if f.len() < 3 {
888 continue;
889 }
890 let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
891 let exact = SearchIndex::postings_intersect(pt, &q);
892 let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
893 for id in exact {
894 assert!(
895 bloom.contains(&id),
896 "Bloom tier dropped a true match (false negative) for {q:?}"
897 );
898 }
899 }
900 }
901
902 #[test]
905 fn bloom_tier_end_to_end_recall() {
906 let samples = [
907 (
908 "auth_login.rs",
909 "fn authenticate(user) {}\nlet token = mint();\n",
910 ),
911 (
912 "auth_session.rs",
913 "struct Session;\n// authenticate again here\n",
914 ),
915 ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
916 (
917 "ui_button.tsx",
918 "export const Button = () => authenticate;\n",
919 ),
920 ("readme.md", "This project uses authenticate flows.\n"),
921 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
922 ];
923 let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
924 let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
925
926 let idx = SearchIndex {
927 files,
928 narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
929 respect_gitignore: true,
930 allow_secret_paths: false,
931 built_at: Instant::now(),
932 };
933 assert!(
934 matches!(idx.narrowing, Narrowing::Blooms(_)),
935 "test must exercise the Bloom tier"
936 );
937
938 for query in ["authenticate", "Session", "retries", "token"] {
939 let cands: HashSet<String> = idx
940 .candidate_paths(query, &[], std::path::Path::new(""))
941 .into_paths()
942 .iter()
943 .map(|p| p.to_string_lossy().to_string())
944 .collect();
945 for (rel, content) in samples {
946 if content.contains(query) {
947 assert!(
948 cands.contains(rel),
949 "Bloom tier dropped real match {rel} for query {query:?}"
950 );
951 }
952 }
953 }
954 }
955
956 struct DataDirGuard {
959 prev: Option<String>,
960 }
961 impl DataDirGuard {
962 fn set(path: &std::path::Path) -> Self {
963 let prev = std::env::var("LEAN_CTX_DATA_DIR").ok();
964 crate::test_env::set_var("LEAN_CTX_DATA_DIR", path);
965 Self { prev }
966 }
967 }
968 impl Drop for DataDirGuard {
969 fn drop(&mut self) {
970 match self.prev.as_deref() {
971 Some(v) => crate::test_env::set_var("LEAN_CTX_DATA_DIR", v),
972 None => crate::test_env::remove_var("LEAN_CTX_DATA_DIR"),
973 }
974 }
975 }
976
977 #[test]
978 fn search_index_lock_name_is_per_repo_and_distinct() {
979 let a = search_index_lock_name("/tmp/repo-a");
980 let b = search_index_lock_name("/tmp/repo-b");
981 assert!(a.starts_with("search-idx-"), "unexpected lock name: {a}");
982 assert_ne!(a, b, "lock name must be per-repo");
983 assert_eq!(a, search_index_lock_name("/tmp/repo-a"), "stable per repo");
984 let h = &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8];
987 assert_ne!(
988 a,
989 format!("graph-idx-{h}"),
990 "must not collide with graph lock"
991 );
992 assert_ne!(
993 a,
994 format!("bm25-idx-{h}"),
995 "must not collide with bm25 lock"
996 );
997 }
998
999 #[test]
1000 fn build_guarded_builds_when_uncontended() {
1001 let _env = crate::core::data_dir::test_env_lock();
1002 let data = tempfile::tempdir().unwrap();
1003 let _guard = DataDirGuard::set(data.path());
1004
1005 let dir = corpus();
1006 let root = dir.path().to_string_lossy().to_string();
1007 {
1009 let mut map = cache()
1010 .lock()
1011 .unwrap_or_else(std::sync::PoisonError::into_inner);
1012 map.insert(
1013 root.clone(),
1014 CacheEntry {
1015 index: None,
1016 building: true,
1017 },
1018 );
1019 }
1020 assert_eq!(
1021 build_guarded(&root, true, false),
1022 BuildOutcome::Built,
1023 "an uncontended root must build"
1024 );
1025 let map = cache()
1026 .lock()
1027 .unwrap_or_else(std::sync::PoisonError::into_inner);
1028 let entry = map.get(&root).expect("entry present");
1029 assert!(!entry.building, "building flag must clear after build");
1030 assert!(entry.index.is_some(), "index must be installed after build");
1031 }
1032
1033 #[test]
1034 fn build_guarded_defers_when_another_process_holds_the_lock() {
1035 let _env = crate::core::data_dir::test_env_lock();
1036 let data = tempfile::tempdir().unwrap();
1037 let _guard = DataDirGuard::set(data.path());
1038
1039 let dir = corpus();
1040 let root = dir.path().to_string_lossy().to_string();
1041 let lock_path = data
1045 .path()
1046 .join(format!(".{}.lock", search_index_lock_name(&root)));
1047 std::fs::write(&lock_path, format!("{}\n", std::process::id())).unwrap();
1048
1049 {
1050 let mut map = cache()
1051 .lock()
1052 .unwrap_or_else(std::sync::PoisonError::into_inner);
1053 map.insert(
1054 root.clone(),
1055 CacheEntry {
1056 index: None,
1057 building: true,
1058 },
1059 );
1060 }
1061 assert_eq!(
1062 build_guarded(&root, true, false),
1063 BuildOutcome::Deferred,
1064 "a contended root must defer the proactive pre-warm"
1065 );
1066 let map = cache()
1067 .lock()
1068 .unwrap_or_else(std::sync::PoisonError::into_inner);
1069 let entry = map.get(&root).expect("entry present");
1070 assert!(
1071 !entry.building,
1072 "deferred build must clear the in-flight flag so a later nudge retries"
1073 );
1074 assert!(
1075 entry.index.is_none(),
1076 "deferred build must not run a second walk / install an index"
1077 );
1078 }
1079}