lean_ctx/core/search_index.rs
1//! Resident line-search index for `ctx_search` (Phase 1 of the efficiency epic).
2//!
3//! Historically `ctx_search` walked the filesystem, read every file, and ran a
4//! regex on every line on *every* call — `O(files × lines)`. That is the
5//! 40–200 ms latency floor this module eliminates.
6//!
7//! This module keeps a RAM-resident trigram index (`trigram → file ids`) so the
8//! common case (an identifier / literal query) collapses to: intersect a few
9//! posting lists in memory → read & regex-verify only the handful of candidate
10//! files. The index never decides matches itself; it only *narrows the file
11//! set*, then `ctx_search` verifies candidates with the exact same regex loop,
12//! so the returned `file:line` hits are byte-identical to the walk path.
13//!
14//! Design notes:
15//! - The index is built with the *same* walk config and file filters as
16//! `ctx_search` (see `tools::ctx_search`) so the searchable universe
17//! is identical — that is what guarantees recall parity.
18//! - Only `[A-Za-z0-9_]` trigrams are indexed. Lookups only ever use trigrams
19//! from pure-identifier queries, so this is both sufficient and memory-bounded.
20//! - Narrowing is applied *only* for pure `[A-Za-z0-9_]` literal queries (the
21//! dominant agent case). Any query containing a regex metacharacter falls
22//! back to scanning the cached file list (still skips the directory walk).
23//! - Freshness is a function of *corpus state*, not the clock: the build records
24//! a cheap, order-independent signature over the eligible files' `(path,
25//! mtime, size)`, and every [`get_fresh`] re-derives it via a stat-only walk
26//! that shares the build's exact filter path. The resident index is served
27//! only when the signature matches live disk — so an edit, *even through a
28//! tool lean-ctx never observes* (native editors, `git checkout`), is
29//! reflected on the very next search instead of lingering for a TTL window.
30//! A push-based fs-watcher (zero per-lookup cost) is a possible future
31//! optimization on top of this correctness gate.
32
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Mutex, OnceLock};
36use std::time::{Duration, Instant};
37
38use glob::Pattern;
39use ignore::WalkBuilder;
40
41use crate::core::search_walk::{MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file};
42
43/// Upper bound on indexed files; larger trees fall back to the walk path.
44const MAX_FILES: usize = 200_000;
45
46/// Posting-entry budget (`file_id` occurrences across all trigrams). Up to this
47/// many entries we keep exact inverted posting lists (fastest, sparse lookups).
48/// Beyond it we switch to the per-file Bloom tier instead of giving up — see
49/// [`Narrowing`]. ~4 bytes each → ~48 MB before the switch.
50const MAX_POSTING_ENTRIES: usize = 12_000_000;
51
52/// Hard ceiling on total trigram entries collected during a build. Past this we
53/// abandon indexing (walk fallback) to avoid pathological memory use even with
54/// the compact Bloom tier.
55const MAX_TOTAL_ENTRIES: usize = 48_000_000;
56
57/// Bloom tuning: bits per distinct trigram and number of hash probes. ~12 bits
58/// with k=7 keeps the false-positive rate well under 1% — and a false positive
59/// only costs one extra regex-verified file read (never a missed match).
60const BLOOM_BITS_PER_ITEM: usize = 12;
61const BLOOM_K: usize = 7;
62/// Per-file Bloom size clamp (in bits): 64 bits min, 1 Mi bits (128 KiB) max.
63const BLOOM_MIN_BITS: usize = 64;
64const BLOOM_MAX_BITS: usize = 1 << 20;
65
66/// A trigram is indexable only if all three bytes are `[A-Za-z0-9_]`.
67fn is_word_byte(b: u8) -> bool {
68 b.is_ascii_alphanumeric() || b == b'_'
69}
70
71fn pack(b0: u8, b1: u8, b2: u8) -> u32 {
72 (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2)
73}
74
75/// How candidate files are narrowed for a literal query. Two tiers, chosen by
76/// corpus size, both providing a *superset* of true matches (zero false
77/// negatives) which `ctx_search` then regex-verifies:
78/// - `Postings`: exact inverted lists `trigram → sorted file ids`. Fast, sparse
79/// lookups; used while total entries fit [`MAX_POSTING_ENTRIES`].
80/// - `Blooms`: one compact per-file Bloom filter of the file's trigrams. ~3×
81/// smaller than postings, so monorepos that would otherwise blow the posting
82/// budget still get index-narrowing instead of a full directory walk.
83enum Narrowing {
84 Postings(HashMap<u32, Vec<u32>>),
85 Blooms(Vec<FileBloom>),
86}
87
88/// A per-file Bloom filter over the file's word-trigrams. No false negatives:
89/// if any probed bit for a trigram is unset, the file provably lacks it.
90struct FileBloom {
91 /// Bit storage; the filter width `m = bits.len() * 64` is a power of two.
92 bits: Vec<u64>,
93}
94
95/// 64-bit avalanche mix (splitmix64 finalizer) — spreads a packed trigram into
96/// a well-distributed hash for double-probing.
97#[inline]
98fn mix64(mut x: u64) -> u64 {
99 x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
100 x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
101 x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
102 x ^ (x >> 31)
103}
104
105impl FileBloom {
106 fn with_capacity(distinct_trigrams: usize) -> Self {
107 let target = distinct_trigrams
108 .saturating_mul(BLOOM_BITS_PER_ITEM)
109 .next_power_of_two()
110 .clamp(BLOOM_MIN_BITS, BLOOM_MAX_BITS);
111 FileBloom {
112 bits: vec![0u64; target / 64],
113 }
114 }
115
116 #[inline]
117 fn m_bits(&self) -> usize {
118 self.bits.len() * 64
119 }
120
121 /// Double hashing: `p_i = h1 + i·h2 (mod m)` with `m` a power of two.
122 #[inline]
123 fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
124 let m = self.m_bits();
125 let mask = m - 1; // m is a power of two
126 let h = mix64(u64::from(trigram));
127 let h1 = (h & 0xFFFF_FFFF) as usize;
128 let h2 = ((h >> 32) as usize) | 1; // odd step → full-period probing
129 (0..BLOOM_K).map(move |i| h1.wrapping_add(i.wrapping_mul(h2)) & mask)
130 }
131
132 fn insert(&mut self, trigram: u32) {
133 for p in self.probes(trigram).collect::<Vec<_>>() {
134 self.bits[p / 64] |= 1u64 << (p % 64);
135 }
136 }
137
138 fn maybe_contains(&self, trigram: u32) -> bool {
139 self.probes(trigram)
140 .all(|p| self.bits[p / 64] & (1u64 << (p % 64)) != 0)
141 }
142}
143
144/// RAM-resident trigram index over one project root.
145pub struct SearchIndex {
146 files: Vec<PathBuf>,
147 /// Candidate-narrowing structure (exact postings or compact per-file Bloom).
148 narrowing: Narrowing,
149 respect_gitignore: bool,
150 allow_secret_paths: bool,
151 /// Signature of the on-disk corpus this index was built from — the freshness
152 /// truth checked on every [`get_fresh`] (see [`corpus_signature`]).
153 signature: u64,
154 /// Admission-policy epoch the indexed text was admitted under (G5): an
155 /// index from an older policy is never served.
156 policy_epoch: u64,
157}
158
159impl SearchIndex {
160 /// Build the index by walking `root` with the exact same config and filters
161 /// as `ctx_search`, so the searchable file universe is identical.
162 pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
163 let mut files: Vec<PathBuf> = Vec::new();
164 // Per-file sorted, deduped trigrams. Same memory as the posting lists
165 // would be, but grouped by file so we can materialise *either* tier
166 // afterwards without a second pass over the corpus.
167 let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
168 let mut total_entries: usize = 0;
169 let mut scratch: HashSet<u32> = HashSet::new();
170 // Corpus-signature accumulators. The running sum is order-independent
171 // (commutative `wrapping_add`) and the file count is folded in at the end
172 // so a change that happens to cancel under addition is still detected.
173 // Folded over the *eligible-by-stat* universe — before the read below —
174 // so the stat-only re-walk in [`corpus_signature`] reproduces it exactly,
175 // including files that turn out to be binary (counted, never indexed).
176 let mut sig_sum: u64 = 0;
177 let mut file_count: usize = 0;
178 let mut aborted = false;
179 // One admission policy for the whole build (G5).
180 let admission = crate::core::context_admission::stores::StoreAdmission::current();
181
182 walk_index_corpus(
183 root,
184 respect_gitignore,
185 allow_secret_paths,
186 None,
187 |path, state| {
188 sig_sum = sig_sum.wrapping_add(file_sig(path, state));
189 file_count += 1;
190
191 // Read the corpus exactly once (issue #148): reuse a fresh cached
192 // copy if a prior `ctx_search`/build already read this file, else
193 // read it now and publish it so the upcoming `ctx_search` verify
194 // pass is an in-memory hit instead of a second disk read. Mirrors
195 // ctx_search: a binary file is never searchable, so it is skipped
196 // for trigrams (but already folded into the signature above).
197 let content: std::sync::Arc<str> = if let Some(cached) =
198 state.and_then(|s| crate::core::content_cache::get(path, s))
199 {
200 cached
201 } else {
202 // Only admitted text enters the shared cache; withheld and
203 // restricted sources are never searchable (G5, E3).
204 let Some(text) = admission.read(path) else {
205 return true;
206 };
207 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
208 if let Some(s) = state {
209 crate::core::content_cache::insert(
210 path,
211 s,
212 std::sync::Arc::clone(&arc),
213 &admission,
214 );
215 }
216 arc
217 };
218
219 if files.len() >= MAX_FILES {
220 aborted = true; // too large even for the Bloom tier — use the walk
221 return false;
222 }
223
224 scratch.clear();
225 let bytes = content.as_bytes();
226 if bytes.len() >= 3 {
227 for w in bytes.windows(3) {
228 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
229 scratch.insert(pack(w[0], w[1], w[2]));
230 }
231 }
232 }
233 total_entries += scratch.len();
234 if total_entries > MAX_TOTAL_ENTRIES {
235 aborted = true; // memory guard — fall back to walk
236 return false;
237 }
238 let mut tris: Vec<u32> = scratch.iter().copied().collect();
239 tris.sort_unstable();
240 files.push(path.to_path_buf());
241 per_file_trigrams.push(tris);
242 true
243 },
244 )?;
245
246 if aborted {
247 return None;
248 }
249
250 let narrowing = build_narrowing(&per_file_trigrams, total_entries);
251
252 Some(Self {
253 files,
254 narrowing,
255 respect_gitignore,
256 allow_secret_paths,
257 signature: finalize_sig(sig_sum, file_count),
258 policy_epoch: admission.epoch(),
259 })
260 }
261
262 fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
263 self.respect_gitignore == respect_gitignore
264 && self.allow_secret_paths == allow_secret_paths
265 && self.policy_epoch == crate::core::context_admission::stores::current_epoch()
266 }
267
268 /// Candidate files for `pattern`, filtered by the `include` glob (matched
269 /// against each file's path relative to `root`). `None` means "no safe
270 /// narrowing possible" — the caller should scan the full file list.
271 ///
272 /// Narrowing is applied only for pure `[A-Za-z0-9_]` literals of length ≥ 3.
273 /// For such a literal every match contains it on a single line, hence the
274 /// file contains all of its consecutive trigrams: intersecting their
275 /// posting lists yields a *superset* of matching files (zero false
276 /// negatives), which the caller then regex-verifies.
277 pub fn candidate_paths(
278 &self,
279 pattern: &str,
280 includes: &[Pattern],
281 root: &Path,
282 ) -> CandidateSet {
283 if let Some(ids) = self.literal_candidates(pattern) {
284 let paths = ids
285 .into_iter()
286 .map(|id| self.files[id as usize].clone())
287 .filter(|p| glob_matches(p, includes, root))
288 .collect();
289 CandidateSet::Narrowed(paths)
290 } else {
291 let paths = self
292 .files
293 .iter()
294 .filter(|p| glob_matches(p, includes, root))
295 .cloned()
296 .collect();
297 CandidateSet::FullList(paths)
298 }
299 }
300
301 /// How many indexed files the `include`/`exclude` filters admit — the
302 /// search's scope before trigram narrowing. #1994: a narrowed miss scans
303 /// no file at all, so without this a genuine miss and a filter that
304 /// matched nothing both reported zero files.
305 pub fn scope_count(&self, includes: &[Pattern], excludes: &[Pattern], root: &Path) -> usize {
306 self.files
307 .iter()
308 .filter(|p| glob_matches(p, includes, root))
309 .filter(|p| excludes.is_empty() || !glob_matches(p, excludes, root))
310 .count()
311 }
312
313 /// Returns candidate file ids for a pure-literal query, or `None` if the
314 /// query is not a trigram-narrowable pure `[A-Za-z0-9_]` literal. Both tiers
315 /// return a *superset* of true matches (zero false negatives).
316 fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
317 let bytes = pattern.as_bytes();
318 if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
319 return None;
320 }
321 // Distinct trigrams of the literal.
322 let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
323 tris.sort_unstable();
324 tris.dedup();
325
326 match &self.narrowing {
327 Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
328 Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
329 }
330 }
331
332 /// Exact-tier: intersect the posting lists of every required trigram
333 /// (smallest first for a cheap intersection).
334 fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
335 let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
336 for &tri in tris {
337 match trigrams.get(&tri) {
338 // A required trigram is absent → provably no match anywhere.
339 None => return Vec::new(),
340 Some(list) => lists.push(list),
341 }
342 }
343 lists.sort_by_key(|l| l.len());
344
345 let mut acc: Vec<u32> = lists[0].clone();
346 for list in &lists[1..] {
347 acc = intersect_sorted(&acc, list);
348 if acc.is_empty() {
349 break;
350 }
351 }
352 acc
353 }
354
355 /// Bloom-tier: a file is a candidate iff its Bloom filter may contain every
356 /// required trigram. No false negatives (an unset probe bit ⇒ the trigram is
357 /// provably absent), so the result is still a superset of true matches.
358 fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
359 let mut out = Vec::new();
360 for (fid, bloom) in blooms.iter().enumerate() {
361 if tris.iter().all(|&t| bloom.maybe_contains(t)) {
362 out.push(fid as u32);
363 }
364 }
365 out
366 }
367}
368
369/// Materialise the appropriate narrowing tier for a freshly walked corpus.
370fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
371 if total_entries <= MAX_POSTING_ENTRIES {
372 let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
373 for (fid, tris) in per_file.iter().enumerate() {
374 for &t in tris {
375 // file ids are appended in ascending order ⇒ lists stay sorted.
376 trigrams.entry(t).or_default().push(fid as u32);
377 }
378 }
379 Narrowing::Postings(trigrams)
380 } else {
381 let blooms = per_file
382 .iter()
383 .map(|tris| {
384 let mut b = FileBloom::with_capacity(tris.len());
385 for &t in tris {
386 b.insert(t);
387 }
388 b
389 })
390 .collect();
391 Narrowing::Blooms(blooms)
392 }
393}
394
395/// Result of [`SearchIndex::candidate_paths`].
396pub enum CandidateSet {
397 /// Trigram-narrowed candidate files (a superset of real matches).
398 Narrowed(Vec<PathBuf>),
399 /// No safe narrowing — the full cached file list (still skips the walk).
400 FullList(Vec<PathBuf>),
401}
402
403impl CandidateSet {
404 pub fn into_paths(self) -> Vec<PathBuf> {
405 match self {
406 CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
407 }
408 }
409}
410
411/// True when `path` matches *any* of the `includes` globs (relative to `root`),
412/// or when there is no filter (`includes` empty).
413fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
414 if includes.is_empty() {
415 return true;
416 }
417 let rel = path.strip_prefix(root).unwrap_or(path);
418 let rel_str = rel.to_string_lossy();
419 includes.iter().any(|p| p.matches(&rel_str))
420}
421
422/// Secret-like files under `root` that match `includes`, for boundary-skip
423/// reporting on the trigram-index fast path (#1481). The include filter runs
424/// before the secret-like check so the note reflects only files the query
425/// would have searched.
426pub fn boundary_skipped_files(
427 root: &str,
428 respect_gitignore: bool,
429 includes: &[Pattern],
430) -> Vec<(String, &'static str)> {
431 let root_path = Path::new(root);
432 if !root_path.exists() {
433 return Vec::new();
434 }
435 if !crate::core::graph_index::is_safe_scan_root_public(root) {
436 return Vec::new();
437 }
438
439 let walker = WalkBuilder::new(root_path)
440 .hidden(false)
441 .max_depth(Some(crate::core::search_walk::MAX_WALK_DEPTH))
442 .git_ignore(respect_gitignore)
443 .git_global(respect_gitignore)
444 .git_exclude(respect_gitignore)
445 .require_git(false)
446 .filter_entry(move |e| {
447 if respect_gitignore {
448 crate::core::walk_filter::keep_entry(e)
449 } else {
450 crate::core::cloud_files::keep_entry(e)
451 }
452 })
453 .build();
454
455 let mut skipped = Vec::new();
456 for entry in walker.filter_map(std::result::Result::ok) {
457 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
458 continue;
459 }
460 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
461 continue;
462 }
463 let path = entry.path();
464 if is_binary_ext(path) || is_generated_file(path) {
465 continue;
466 }
467 if !includes.is_empty() && !glob_matches(path, includes, root_path) {
468 continue;
469 }
470 if let Some(reason) = crate::core::io_boundary::is_secret_like(path) {
471 let rel = path.strip_prefix(root_path).unwrap_or(path);
472 skipped.push((rel.to_string_lossy().into_owned(), reason));
473 }
474 }
475 skipped.sort_unstable();
476 skipped
477}
478
479/// Intersection of two ascending, deduped `u32` slices.
480fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
481 let mut out = Vec::new();
482 let (mut i, mut j) = (0, 0);
483 while i < a.len() && j < b.len() {
484 match a[i].cmp(&b[j]) {
485 std::cmp::Ordering::Less => i += 1,
486 std::cmp::Ordering::Greater => j += 1,
487 std::cmp::Ordering::Equal => {
488 out.push(a[i]);
489 i += 1;
490 j += 1;
491 }
492 }
493 }
494 out
495}
496
497// ---------------------------------------------------------------------------
498// Corpus freshness: the build and its freshness check share one traversal so
499// the signature is computed over an identical file universe (parity is what
500// makes the equality check trustworthy).
501// ---------------------------------------------------------------------------
502
503/// Walk `root` with the *exact* `ctx_search` file universe — same gitignore
504/// semantics, depth limit, and binary/generated/secret/size/regular-file guards
505/// — invoking `visit(path, state)` for every eligible regular file with its
506/// `(mtime, size)` identity (`None` only when the platform cannot report mtime).
507/// `visit` returns `false` to stop early. Returns `None` for a missing or unsafe
508/// scan root (HOME, fs root, …), the same guard that makes the caller fall back
509/// to a direct walk. This traversal reads nothing: it is the shared spine of
510/// both [`SearchIndex::build`] and [`corpus_signature`].
511fn walk_index_corpus<F>(
512 root: &str,
513 respect_gitignore: bool,
514 allow_secret_paths: bool,
515 includes: Option<&[Pattern]>,
516 mut visit: F,
517) -> Option<()>
518where
519 F: FnMut(&Path, Option<crate::core::content_cache::FileState>) -> bool,
520{
521 let root_path = Path::new(root);
522 if !root_path.exists() {
523 return None;
524 }
525 // Never auto-index a broad/unsafe root (HOME, filesystem root, a dir with
526 // dozens of unrelated subtrees). Mirrors the graph/BM25 guard and stops a
527 // walk of the whole home directory — which on Windows would hydrate OneDrive
528 // placeholders (#363).
529 if !crate::core::graph_index::is_safe_scan_root_public(root) {
530 return None;
531 }
532
533 let walker = WalkBuilder::new(root_path)
534 // #1792: same corpus rule as the BM25 walk above — see
535 // `walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK`.
536 .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
537 .max_depth(Some(MAX_WALK_DEPTH))
538 .git_ignore(respect_gitignore)
539 .git_global(respect_gitignore)
540 .git_exclude(respect_gitignore)
541 .require_git(false)
542 .filter_entry(crate::core::walk_filter::keep_entry)
543 .build();
544
545 for entry in walker.filter_map(std::result::Result::ok) {
546 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
547 continue;
548 }
549 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
550 continue;
551 }
552 let path = entry.path();
553 if is_binary_ext(path) || is_generated_file(path) {
554 continue;
555 }
556 if let Some(includes) = includes {
557 if !includes.is_empty() && !glob_matches(path, includes, root_path) {
558 continue;
559 }
560 }
561 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
562 continue;
563 }
564 // `metadata` (stat) never opens the file, so it cannot block on a
565 // FIFO/socket/device node (#336); those are filtered out here.
566 let state = match std::fs::metadata(path) {
567 Ok(meta) if !meta.file_type().is_file() => continue,
568 Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
569 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
570 Err(_) => continue,
571 };
572 if !visit(path, state) {
573 break;
574 }
575 }
576 Some(())
577}
578
579/// Stable per-file contribution to the corpus signature: folds the path with its
580/// `(mtime, size)` identity. Order-independent (callers combine with
581/// `wrapping_add`), so traversal order never affects the result. A file with no
582/// resolvable mtime contributes its path only — add/rename/delete are still
583/// detected, while an in-place edit on such an exotic filesystem is the same
584/// blind spot the `(mtime, size)` content cache already documents.
585fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
586 let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
587 for &b in path.as_os_str().as_encoded_bytes() {
588 h ^= u64::from(b);
589 h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime
590 }
591 if let Some(st) = state {
592 h ^= mix64(st.mtime_ms).rotate_left(1);
593 h ^= mix64(st.size_bytes).rotate_left(33);
594 }
595 mix64(h)
596}
597
598/// Fold the order-independent per-file sum together with the file count into the
599/// final signature, so adding and removing files whose hashes cancel under
600/// addition still changes the result.
601fn finalize_sig(sum: u64, count: usize) -> u64 {
602 sum ^ mix64(count as u64).rotate_left(32)
603}
604
605/// Cheap, stat-only signature of the *current* on-disk corpus for `root`.
606/// Compared against `SearchIndex::signature` in [`get_fresh`] to detect any
607/// change — content edits (including via tools lean-ctx never observes),
608/// additions, renames and deletions — so a stale candidate set can never
609/// silently drop a real match. `None` mirrors a non-indexable root (the caller
610/// then walks directly).
611pub fn corpus_signature(
612 root: &str,
613 respect_gitignore: bool,
614 allow_secret_paths: bool,
615) -> Option<u64> {
616 let mut sum: u64 = 0;
617 let mut count: usize = 0;
618 walk_index_corpus(
619 root,
620 respect_gitignore,
621 allow_secret_paths,
622 None,
623 |path, state| {
624 sum = sum.wrapping_add(file_sig(path, state));
625 count += 1;
626 true
627 },
628 )?;
629 Some(finalize_sig(sum, count))
630}
631
632// ---------------------------------------------------------------------------
633// Resident cache (one index per project root) with background (re)build.
634// ---------------------------------------------------------------------------
635
636struct CacheEntry {
637 index: Option<Arc<SearchIndex>>,
638 building: bool,
639 /// When this root's resident index was last confirmed current against disk.
640 /// Gates the optional coalesce window in [`get_fresh`]; `None` forces a fresh
641 /// verification on the next lookup.
642 last_verified: Option<Instant>,
643}
644
645impl CacheEntry {
646 fn empty() -> Self {
647 Self {
648 index: None,
649 building: false,
650 last_verified: None,
651 }
652 }
653}
654
655static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
656
657fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
658 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
659}
660
661/// Max project roots with a resident index. The daemon builds one for every root
662/// its sessions touch (worktrees, branches, sibling repos) and kept each of them
663/// for its whole lifetime (#2075). LRU-evict beyond this, as `graph_cache` does;
664/// an evicted root walks once and rebuilds on its next search.
665const MAX_RESIDENT_ROOTS: usize = 8;
666
667/// Keep at most [`MAX_RESIDENT_ROOTS`] resident indexes, dropping the least
668/// recently verified first. `keep` (the root just installed) is never dropped.
669fn enforce_root_cap(map: &mut HashMap<String, CacheEntry>, keep: &str) {
670 while map.values().filter(|e| e.index.is_some()).count() > MAX_RESIDENT_ROOTS {
671 let Some(lru) = map
672 .iter()
673 .filter(|(root, e)| e.index.is_some() && root.as_str() != keep)
674 .min_by_key(|(_, e)| e.last_verified)
675 .map(|(root, _)| root.clone())
676 else {
677 break;
678 };
679 // An in-flight rebuild keeps its entry so its `building` flag survives.
680 match map.get_mut(&lru) {
681 Some(entry) if entry.building => {
682 entry.index = None;
683 entry.last_verified = None;
684 }
685 _ => {
686 map.remove(&lru);
687 }
688 }
689 }
690}
691
692/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
693/// everywhere (debugging / A-B measurement / opt-out).
694fn index_disabled() -> bool {
695 std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
696 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
697}
698
699/// Optional coalescing window for the freshness check. Default `0` (disabled):
700/// every [`get_fresh`] re-verifies the corpus signature against disk, so a fresh
701/// edit is never missed. On very large indexed trees, set
702/// `LEAN_CTX_SEARCH_INDEX_COALESCE_MS` to trade a bounded staleness window for
703/// fewer stat-walks under bursty search load.
704fn coalesce_window() -> Option<Duration> {
705 let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
706 .ok()
707 .and_then(|v| v.trim().parse::<u64>().ok())
708 .unwrap_or(0);
709 (ms > 0).then(|| Duration::from_millis(ms))
710}
711
712/// Returns a fresh resident index for `root` if one is available for the given
713/// config, otherwise spawns a background (re)build and returns `None` so the
714/// caller uses the walk fallback for this call.
715pub fn get_fresh(
716 root: &str,
717 respect_gitignore: bool,
718 allow_secret_paths: bool,
719) -> Option<Arc<SearchIndex>> {
720 // Privileged "ignore gitignore" scans are rare and bypass the index.
721 if !respect_gitignore || index_disabled() {
722 return None;
723 }
724
725 // Phase 1 (locked, O(1)): grab the resident index for this config, if any,
726 // together with when it was last verified against disk.
727 let (candidate, last_verified) = {
728 let map = cache()
729 .lock()
730 .unwrap_or_else(std::sync::PoisonError::into_inner);
731 match map.get(root) {
732 Some(entry) => match &entry.index {
733 Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
734 (Some(Arc::clone(idx)), entry.last_verified)
735 }
736 _ => (None, None),
737 },
738 None => (None, None),
739 }
740 };
741
742 let Some(idx) = candidate else {
743 // No usable index yet → build in the background, walk this call.
744 request_build(root, respect_gitignore, allow_secret_paths);
745 return None;
746 };
747
748 // Coalesce (opt-in, default off): inside the window trust the recent
749 // verification and skip the stat-walk — keeps bursty search load O(1).
750 if let Some(window) = coalesce_window()
751 && last_verified.is_some_and(|t| t.elapsed() < window)
752 {
753 return Some(idx);
754 }
755
756 // Phase 2 (unlocked): verify the live corpus signature. Deliberately held
757 // *outside* the cache mutex so a multi-millisecond stat-walk never serializes
758 // other roots or concurrent searches.
759 match corpus_signature(root, respect_gitignore, allow_secret_paths) {
760 Some(sig) if sig == idx.signature => {
761 mark_verified(root);
762 Some(idx)
763 }
764 _ => {
765 // Corpus changed (or root no longer indexable): walk accurately now
766 // and rebuild the index in the background for the next call.
767 request_build(root, respect_gitignore, allow_secret_paths);
768 None
769 }
770 }
771}
772
773/// Drop every resident trigram index (#685 eviction hook). In-flight builds
774/// keep their `building` flag (the entry is reset, not the build thread), so
775/// a running build still installs its result; subsequent searches fall back
776/// to the walk path until the next `ensure_background` rebuilds. Correctness
777/// is unaffected — the resident index is purely an accelerator. Returns whether
778/// any index was dropped.
779pub fn clear_resident() -> bool {
780 let mut map = cache()
781 .lock()
782 .unwrap_or_else(std::sync::PoisonError::into_inner);
783 let mut dropped = false;
784 for entry in map.values_mut() {
785 dropped |= entry.index.take().is_some();
786 entry.last_verified = None;
787 }
788 dropped
789}
790
791/// Record that `root`'s resident index was just confirmed current, extending its
792/// coalesce window.
793fn mark_verified(root: &str) {
794 let mut map = cache()
795 .lock()
796 .unwrap_or_else(std::sync::PoisonError::into_inner);
797 if let Some(entry) = map.get_mut(root) {
798 entry.last_verified = Some(Instant::now());
799 }
800}
801
802/// Spawn a background (re)build for `root` unless one is already in flight.
803fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
804 let needs_build = {
805 let mut map = cache()
806 .lock()
807 .unwrap_or_else(std::sync::PoisonError::into_inner);
808 let entry = map
809 .entry(root.to_string())
810 .or_insert_with(CacheEntry::empty);
811 if entry.building {
812 false
813 } else {
814 entry.building = true;
815 true
816 }
817 };
818 if needs_build {
819 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
820 }
821}
822
823/// Ensure a resident index for `root` is built (or building) in the background.
824/// Safe to call repeatedly; deduped via the per-root `building` flag.
825pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
826 if !respect_gitignore || index_disabled() {
827 return;
828 }
829 // Prewarm only when there is no usable index yet — staleness of an existing
830 // index is corrected on demand by `get_fresh`'s signature gate, so there is
831 // nothing to proactively refresh here.
832 let has_index = {
833 let map = cache()
834 .lock()
835 .unwrap_or_else(std::sync::PoisonError::into_inner);
836 map.get(root).is_some_and(|entry| {
837 entry
838 .index
839 .as_ref()
840 .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
841 })
842 };
843 if !has_index {
844 request_build(root, respect_gitignore, allow_secret_paths);
845 }
846}
847
848/// Build the index synchronously and install it in the resident cache.
849/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
850/// guaranteed-warm index. Respects the disable env var.
851pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
852 if !respect_gitignore || index_disabled() {
853 return false;
854 }
855 let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
856 return false;
857 };
858 let mut map = cache()
859 .lock()
860 .unwrap_or_else(std::sync::PoisonError::into_inner);
861 map.insert(
862 root.to_string(),
863 CacheEntry {
864 index: Some(Arc::new(idx)),
865 building: false,
866 last_verified: Some(Instant::now()),
867 },
868 );
869 enforce_root_cap(&mut map, root);
870 true
871}
872
873/// Per-repo lock name serializing the resident search-index build across
874/// processes, mirroring the `graph-idx` / `bm25-idx` locks in
875/// [`crate::core::index_orchestrator`]. Distinct `search-` prefix so the three
876/// indexers never serialize against one another.
877fn search_index_lock_name(root: &str) -> String {
878 format!(
879 "search-idx-{}",
880 &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
881 )
882}
883
884/// Outcome of a guarded background build: did this process do the walk, or did
885/// it yield to another process already building the same root?
886#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887enum BuildOutcome {
888 Built,
889 Deferred,
890}
891
892/// Build the resident index under a cross-process herd guard (#460).
893///
894/// The trigram index is RAM-resident (not shareable on disk), so on lock
895/// contention we *defer* the proactive pre-warm instead of running a second
896/// simultaneous file walk: a boot wave of N sessions on one repo then triggers
897/// ~1 walk at a time, not N. Deferring is safe — `ctx_search` still works via
898/// its walk fallback, and the per-process `building` flag is cleared so the next
899/// `get_fresh` / `ensure_background` nudge retries once the holder releases. The
900/// short 200 ms wait keeps the common single-session path latency-free.
901fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
902 let lock = crate::core::startup_guard::try_acquire_lock(
903 &search_index_lock_name(root),
904 Duration::from_millis(200),
905 Duration::from_mins(3),
906 );
907 if lock.is_none() {
908 // Another process owns the build. Clear the in-flight flag so a later
909 // nudge retries rather than leaving `building` stuck true forever.
910 let mut map = cache()
911 .lock()
912 .unwrap_or_else(std::sync::PoisonError::into_inner);
913 if let Some(entry) = map.get_mut(root) {
914 entry.building = false;
915 }
916 return BuildOutcome::Deferred;
917 }
918
919 let built = std::panic::catch_unwind(|| {
920 SearchIndex::build(root, respect_gitignore, allow_secret_paths)
921 })
922 .ok()
923 .flatten();
924
925 let mut map = cache()
926 .lock()
927 .unwrap_or_else(std::sync::PoisonError::into_inner);
928 if let Some(entry) = map.get_mut(root) {
929 entry.building = false;
930 if let Some(idx) = built {
931 entry.index = Some(Arc::new(idx));
932 // A just-built index is current with the disk it walked.
933 entry.last_verified = Some(Instant::now());
934 }
935 }
936 enforce_root_cap(&mut map, root);
937 // `lock` is held until here so the cross-process guard spans the whole walk.
938 drop(lock);
939 BuildOutcome::Built
940}
941
942fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
943 std::thread::spawn(move || {
944 let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
945 });
946}
947
948#[cfg(test)]
949#[path = "search_index_tests.rs"]
950mod tests;