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 // ent2-3: files the Hive policy keeps out of context never enter the
534 // index; a policy that cannot be trusted builds none (the search walk
535 // then refuses, fail closed).
536 let hive = crate::core::policy::hive_io::rules_for(root_path).ok()?;
537 let walker = WalkBuilder::new(root_path)
538 // #1792: same corpus rule as the BM25 walk above — see
539 // `walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK`.
540 .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
541 .max_depth(Some(MAX_WALK_DEPTH))
542 .git_ignore(respect_gitignore)
543 .git_global(respect_gitignore)
544 .git_exclude(respect_gitignore)
545 .require_git(false)
546 .filter_entry(crate::core::walk_filter::keep_entry)
547 .build();
548
549 for entry in walker.filter_map(std::result::Result::ok) {
550 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
551 continue;
552 }
553 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
554 continue;
555 }
556 let path = entry.path();
557 if is_binary_ext(path) || is_generated_file(path) {
558 continue;
559 }
560 if let Some(includes) = includes {
561 if !includes.is_empty() && !glob_matches(path, includes, root_path) {
562 continue;
563 }
564 }
565 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
566 continue;
567 }
568 if hive
569 .as_ref()
570 .is_some_and(|hive| hive.refusal(path).is_some())
571 {
572 continue;
573 }
574 // `metadata` (stat) never opens the file, so it cannot block on a
575 // FIFO/socket/device node (#336); those are filtered out here.
576 let state = match std::fs::metadata(path) {
577 Ok(meta) if !meta.file_type().is_file() => continue,
578 Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
579 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
580 Err(_) => continue,
581 };
582 if !visit(path, state) {
583 break;
584 }
585 }
586 Some(())
587}
588
589/// Stable per-file contribution to the corpus signature: folds the path with its
590/// `(mtime, size)` identity. Order-independent (callers combine with
591/// `wrapping_add`), so traversal order never affects the result. A file with no
592/// resolvable mtime contributes its path only — add/rename/delete are still
593/// detected, while an in-place edit on such an exotic filesystem is the same
594/// blind spot the `(mtime, size)` content cache already documents.
595fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
596 let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
597 for &b in path.as_os_str().as_encoded_bytes() {
598 h ^= u64::from(b);
599 h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime
600 }
601 if let Some(st) = state {
602 h ^= mix64(st.mtime_ms).rotate_left(1);
603 h ^= mix64(st.size_bytes).rotate_left(33);
604 }
605 mix64(h)
606}
607
608/// Fold the order-independent per-file sum together with the file count into the
609/// final signature, so adding and removing files whose hashes cancel under
610/// addition still changes the result.
611fn finalize_sig(sum: u64, count: usize) -> u64 {
612 sum ^ mix64(count as u64).rotate_left(32)
613}
614
615/// Cheap, stat-only signature of the *current* on-disk corpus for `root`.
616/// Compared against `SearchIndex::signature` in [`get_fresh`] to detect any
617/// change — content edits (including via tools lean-ctx never observes),
618/// additions, renames and deletions — so a stale candidate set can never
619/// silently drop a real match. `None` mirrors a non-indexable root (the caller
620/// then walks directly).
621pub fn corpus_signature(
622 root: &str,
623 respect_gitignore: bool,
624 allow_secret_paths: bool,
625) -> Option<u64> {
626 let mut sum: u64 = 0;
627 let mut count: usize = 0;
628 walk_index_corpus(
629 root,
630 respect_gitignore,
631 allow_secret_paths,
632 None,
633 |path, state| {
634 sum = sum.wrapping_add(file_sig(path, state));
635 count += 1;
636 true
637 },
638 )?;
639 Some(finalize_sig(sum, count))
640}
641
642// ---------------------------------------------------------------------------
643// Resident cache (one index per project root) with background (re)build.
644// ---------------------------------------------------------------------------
645
646struct CacheEntry {
647 index: Option<Arc<SearchIndex>>,
648 building: bool,
649 /// When this root's resident index was last confirmed current against disk.
650 /// Gates the optional coalesce window in [`get_fresh`]; `None` forces a fresh
651 /// verification on the next lookup.
652 last_verified: Option<Instant>,
653}
654
655impl CacheEntry {
656 fn empty() -> Self {
657 Self {
658 index: None,
659 building: false,
660 last_verified: None,
661 }
662 }
663}
664
665static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
666
667fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
668 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
669}
670
671/// Max project roots with a resident index. The daemon builds one for every root
672/// its sessions touch (worktrees, branches, sibling repos) and kept each of them
673/// for its whole lifetime (#2075). LRU-evict beyond this, as `graph_cache` does;
674/// an evicted root walks once and rebuilds on its next search.
675const MAX_RESIDENT_ROOTS: usize = 8;
676
677/// Keep at most [`MAX_RESIDENT_ROOTS`] resident indexes, dropping the least
678/// recently verified first. `keep` (the root just installed) is never dropped.
679fn enforce_root_cap(map: &mut HashMap<String, CacheEntry>, keep: &str) {
680 while map.values().filter(|e| e.index.is_some()).count() > MAX_RESIDENT_ROOTS {
681 let Some(lru) = map
682 .iter()
683 .filter(|(root, e)| e.index.is_some() && root.as_str() != keep)
684 .min_by_key(|(_, e)| e.last_verified)
685 .map(|(root, _)| root.clone())
686 else {
687 break;
688 };
689 // An in-flight rebuild keeps its entry so its `building` flag survives.
690 match map.get_mut(&lru) {
691 Some(entry) if entry.building => {
692 entry.index = None;
693 entry.last_verified = None;
694 }
695 _ => {
696 map.remove(&lru);
697 }
698 }
699 }
700}
701
702/// Escape hatch: `LEAN_CTX_DISABLE_SEARCH_INDEX=1` forces the walk path
703/// everywhere (debugging / A-B measurement / opt-out).
704fn index_disabled() -> bool {
705 std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
706 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
707}
708
709/// Optional coalescing window for the freshness check. Default `0` (disabled):
710/// every [`get_fresh`] re-verifies the corpus signature against disk, so a fresh
711/// edit is never missed. On very large indexed trees, set
712/// `LEAN_CTX_SEARCH_INDEX_COALESCE_MS` to trade a bounded staleness window for
713/// fewer stat-walks under bursty search load.
714fn coalesce_window() -> Option<Duration> {
715 let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
716 .ok()
717 .and_then(|v| v.trim().parse::<u64>().ok())
718 .unwrap_or(0);
719 (ms > 0).then(|| Duration::from_millis(ms))
720}
721
722/// Returns a fresh resident index for `root` if one is available for the given
723/// config, otherwise spawns a background (re)build and returns `None` so the
724/// caller uses the walk fallback for this call.
725pub fn get_fresh(
726 root: &str,
727 respect_gitignore: bool,
728 allow_secret_paths: bool,
729) -> Option<Arc<SearchIndex>> {
730 // Privileged "ignore gitignore" scans are rare and bypass the index.
731 if !respect_gitignore || index_disabled() {
732 return None;
733 }
734
735 // Phase 1 (locked, O(1)): grab the resident index for this config, if any,
736 // together with when it was last verified against disk.
737 let (candidate, last_verified) = {
738 let map = cache()
739 .lock()
740 .unwrap_or_else(std::sync::PoisonError::into_inner);
741 match map.get(root) {
742 Some(entry) => match &entry.index {
743 Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
744 (Some(Arc::clone(idx)), entry.last_verified)
745 }
746 _ => (None, None),
747 },
748 None => (None, None),
749 }
750 };
751
752 let Some(idx) = candidate else {
753 // No usable index yet → build in the background, walk this call.
754 request_build(root, respect_gitignore, allow_secret_paths);
755 return None;
756 };
757
758 // Coalesce (opt-in, default off): inside the window trust the recent
759 // verification and skip the stat-walk — keeps bursty search load O(1).
760 if let Some(window) = coalesce_window()
761 && last_verified.is_some_and(|t| t.elapsed() < window)
762 {
763 return Some(idx);
764 }
765
766 // Phase 2 (unlocked): verify the live corpus signature. Deliberately held
767 // *outside* the cache mutex so a multi-millisecond stat-walk never serializes
768 // other roots or concurrent searches.
769 match corpus_signature(root, respect_gitignore, allow_secret_paths) {
770 Some(sig) if sig == idx.signature => {
771 mark_verified(root);
772 Some(idx)
773 }
774 _ => {
775 // Corpus changed (or root no longer indexable): walk accurately now
776 // and rebuild the index in the background for the next call.
777 request_build(root, respect_gitignore, allow_secret_paths);
778 None
779 }
780 }
781}
782
783/// Drop every resident trigram index (#685 eviction hook). In-flight builds
784/// keep their `building` flag (the entry is reset, not the build thread), so
785/// a running build still installs its result; subsequent searches fall back
786/// to the walk path until the next `ensure_background` rebuilds. Correctness
787/// is unaffected — the resident index is purely an accelerator. Returns whether
788/// any index was dropped.
789pub fn clear_resident() -> bool {
790 let mut map = cache()
791 .lock()
792 .unwrap_or_else(std::sync::PoisonError::into_inner);
793 let mut dropped = false;
794 for entry in map.values_mut() {
795 dropped |= entry.index.take().is_some();
796 entry.last_verified = None;
797 }
798 dropped
799}
800
801/// Record that `root`'s resident index was just confirmed current, extending its
802/// coalesce window.
803fn mark_verified(root: &str) {
804 let mut map = cache()
805 .lock()
806 .unwrap_or_else(std::sync::PoisonError::into_inner);
807 if let Some(entry) = map.get_mut(root) {
808 entry.last_verified = Some(Instant::now());
809 }
810}
811
812/// Spawn a background (re)build for `root` unless one is already in flight.
813fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
814 let needs_build = {
815 let mut map = cache()
816 .lock()
817 .unwrap_or_else(std::sync::PoisonError::into_inner);
818 let entry = map
819 .entry(root.to_string())
820 .or_insert_with(CacheEntry::empty);
821 if entry.building {
822 false
823 } else {
824 entry.building = true;
825 true
826 }
827 };
828 if needs_build {
829 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
830 }
831}
832
833/// Ensure a resident index for `root` is built (or building) in the background.
834/// Safe to call repeatedly; deduped via the per-root `building` flag.
835pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
836 if !respect_gitignore || index_disabled() {
837 return;
838 }
839 // Prewarm only when there is no usable index yet — staleness of an existing
840 // index is corrected on demand by `get_fresh`'s signature gate, so there is
841 // nothing to proactively refresh here.
842 let has_index = {
843 let map = cache()
844 .lock()
845 .unwrap_or_else(std::sync::PoisonError::into_inner);
846 map.get(root).is_some_and(|entry| {
847 entry
848 .index
849 .as_ref()
850 .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
851 })
852 };
853 if !has_index {
854 request_build(root, respect_gitignore, allow_secret_paths);
855 }
856}
857
858/// Build the index synchronously and install it in the resident cache.
859/// Returns `true` on success. Useful for CLI prewarm and benchmarks that need a
860/// guaranteed-warm index. Respects the disable env var.
861pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
862 if !respect_gitignore || index_disabled() {
863 return false;
864 }
865 let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
866 return false;
867 };
868 let mut map = cache()
869 .lock()
870 .unwrap_or_else(std::sync::PoisonError::into_inner);
871 map.insert(
872 root.to_string(),
873 CacheEntry {
874 index: Some(Arc::new(idx)),
875 building: false,
876 last_verified: Some(Instant::now()),
877 },
878 );
879 enforce_root_cap(&mut map, root);
880 true
881}
882
883/// Per-repo lock name serializing the resident search-index build across
884/// processes, mirroring the `graph-idx` / `bm25-idx` locks in
885/// [`crate::core::index_orchestrator`]. Distinct `search-` prefix so the three
886/// indexers never serialize against one another.
887fn search_index_lock_name(root: &str) -> String {
888 format!(
889 "search-idx-{}",
890 &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
891 )
892}
893
894/// Outcome of a guarded background build: did this process do the walk, or did
895/// it yield to another process already building the same root?
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897enum BuildOutcome {
898 Built,
899 Deferred,
900}
901
902/// Build the resident index under a cross-process herd guard (#460).
903///
904/// The trigram index is RAM-resident (not shareable on disk), so on lock
905/// contention we *defer* the proactive pre-warm instead of running a second
906/// simultaneous file walk: a boot wave of N sessions on one repo then triggers
907/// ~1 walk at a time, not N. Deferring is safe — `ctx_search` still works via
908/// its walk fallback, and the per-process `building` flag is cleared so the next
909/// `get_fresh` / `ensure_background` nudge retries once the holder releases. The
910/// short 200 ms wait keeps the common single-session path latency-free.
911fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
912 let lock = crate::core::startup_guard::try_acquire_lock(
913 &search_index_lock_name(root),
914 Duration::from_millis(200),
915 Duration::from_mins(3),
916 );
917 if lock.is_none() {
918 // Another process owns the build. Clear the in-flight flag so a later
919 // nudge retries rather than leaving `building` stuck true forever.
920 let mut map = cache()
921 .lock()
922 .unwrap_or_else(std::sync::PoisonError::into_inner);
923 if let Some(entry) = map.get_mut(root) {
924 entry.building = false;
925 }
926 return BuildOutcome::Deferred;
927 }
928
929 let built = std::panic::catch_unwind(|| {
930 SearchIndex::build(root, respect_gitignore, allow_secret_paths)
931 })
932 .ok()
933 .flatten();
934
935 let mut map = cache()
936 .lock()
937 .unwrap_or_else(std::sync::PoisonError::into_inner);
938 if let Some(entry) = map.get_mut(root) {
939 entry.building = false;
940 if let Some(idx) = built {
941 entry.index = Some(Arc::new(idx));
942 // A just-built index is current with the disk it walked.
943 entry.last_verified = Some(Instant::now());
944 }
945 }
946 enforce_root_cap(&mut map, root);
947 // `lock` is held until here so the cross-process guard spans the whole walk.
948 drop(lock);
949 BuildOutcome::Built
950}
951
952fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
953 std::thread::spawn(move || {
954 // The build's project is the root it indexes: its files are the
955 // project's own, not unknown stored text (pz21 §1.4), whatever the
956 // process directory of this detached thread is.
957 crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
958 std::cell::RefCell::new(Some(std::path::PathBuf::from(&root))),
959 || {
960 let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
961 },
962 );
963 });
964}
965
966#[cfg(test)]
967#[path = "search_index_tests.rs"]
968mod tests;