1use std::cell::Cell;
2use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
3use std::fs::{self, File, OpenOptions};
4use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
5use std::path::{Component, Path, PathBuf};
6use std::sync::{
7 atomic::{AtomicBool, AtomicUsize, Ordering},
8 Arc, Mutex, OnceLock, RwLock, RwLockReadGuard, TryLockError, Weak,
9};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use rayon::prelude::*;
15use regex::bytes::Regex;
16use regex_syntax::hir::{Hir, HirKind};
17use serde::{Deserialize, Serialize};
18
19use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
20use crate::fs_lock;
21use crate::pattern_compile::{self, CompileOpts, CompileResult, CompiledPattern, LiteralSearch};
22
23#[path = "commands/semantic_search/exact_lane.rs"]
24pub mod exact_lane;
25#[path = "commands/semantic_search/memo.rs"]
26pub mod memo;
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct ExactPassMatch {
30 pub path: PathBuf,
31 pub symbol_range: Option<crate::commands::semantic_search::comparator::SymbolOffsetRange>,
32 pub evidence: crate::commands::semantic_search::evidence_descriptor::EvidenceDescriptor,
33 pub content_digest: String,
34}
35
36pub(crate) const DEFAULT_MAX_FILE_SIZE: u64 = 1_048_576;
37const CACHE_MAGIC: u32 = 0x3144_4958; const INDEX_MAGIC: &[u8; 8] = b"AFTIDX01";
39const LOOKUP_MAGIC: &[u8; 8] = b"AFTLKP01";
40const SPILL_MAGIC: &[u8; 8] = b"AFTSPI01";
41const FILE_TRIGRAM_COUNT_MAGIC: &[u8; 8] = b"AFTFTC01";
42const INDEX_VERSION: u32 = 4;
43const PREVIEW_BYTES: usize = 8 * 1024;
44const SPIMI_SOFT_LIMIT_BYTES: usize = 128 * 1024 * 1024;
45const SPIMI_HARD_LIMIT_BYTES: usize = 256 * 1024 * 1024;
46const SPILL_RECORD_ESTIMATED_BYTES: usize = 16;
47const DELTA_COMPACT_SOFT_FILES: usize = 1_000;
48const DELTA_COMPACT_HARD_FILES: usize = 5_000;
49const DELTA_COMPACT_SOFT_BYTES: usize = 32 * 1024 * 1024;
50const DELTA_COMPACT_HARD_BYTES: usize = 128 * 1024 * 1024;
51const EOF_SENTINEL: u8 = 0;
52const MAX_ENTRIES: usize = 10_000_000;
53const MIN_FILE_ENTRY_BYTES: usize = 57;
54const LOOKUP_ENTRY_BYTES: usize = 16;
55const POSTING_BYTES: usize = 6;
56const ARTIFACT_CACHE_KEY_MEMO_FILE: &str = "cache-keys.json";
57const ARTIFACT_CACHE_KEY_MEMO_EVICTION_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
58const ARTIFACT_CACHE_KEY_MEMO_READ_REFRESH_AGE: Duration = Duration::from_secs(24 * 60 * 60);
59const INDEX_ORPHAN_MIN_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
60const INDEX_ORPHAN_SWEEP_LIMIT: usize = 200;
61const TRANSIENT_SEARCH_CACHE_PREFIX: &str = "aft-search-cache.";
62const TRANSIENT_SEARCH_CACHE_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
66const TRANSIENT_SEARCH_CACHE_SWEEP_LIMIT: usize = 200;
67const TRANSIENT_SEARCH_CACHE_SWEEP_BUDGET: Duration = Duration::from_secs(5);
68static CACHE_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
69static ARTIFACT_CACHE_KEY_MEMO_STATE: OnceLock<Mutex<ArtifactCacheKeyMemoState>> = OnceLock::new();
70static INDEX_ORPHAN_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
71static TRANSIENT_SEARCH_CACHE_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> =
72 OnceLock::new();
73static TRANSIENT_SEARCH_CACHE_BUILD_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> =
74 OnceLock::new();
75
76#[cfg(debug_assertions)]
77thread_local! {
78 static POSTINGS_FOR_TRIGRAM_CALLS: Cell<usize> = const { Cell::new(0) };
79}
80
81#[cfg(debug_assertions)]
82#[doc(hidden)]
83pub fn reset_postings_for_trigram_count_for_debug() {
84 POSTINGS_FOR_TRIGRAM_CALLS.with(|calls| calls.set(0));
85}
86
87#[cfg(debug_assertions)]
88#[doc(hidden)]
89pub fn postings_for_trigram_count_for_debug() -> usize {
90 POSTINGS_FOR_TRIGRAM_CALLS.with(Cell::get)
91}
92
93#[cfg(test)]
94type RootCommitProbeOverride =
95 Arc<dyn Fn(&Path) -> Option<RootCommitProbe> + Send + Sync + 'static>;
96
97#[cfg(test)]
98static GIT_ROOT_COMMIT_PROBE_OVERRIDE: OnceLock<Mutex<Option<RootCommitProbeOverride>>> =
99 OnceLock::new();
100
101#[derive(Clone, Debug, Deserialize, Serialize)]
102struct ArtifactCacheKeyMemoEntry {
103 key: String,
104 git_root_commit: String,
105 recorded_at_ms: u64,
106}
107
108#[derive(Default)]
109struct ArtifactCacheKeyMemoState {
110 by_storage_root: BTreeMap<PathBuf, BTreeMap<String, ArtifactCacheKeyMemoEntry>>,
111}
112
113#[derive(Default)]
114struct IndexOrphanSweepSummary {
115 scanned: usize,
116 removed: usize,
117 skipped_derived: usize,
118 skipped_memo: usize,
119 skipped_fresh: usize,
120 skipped_live: usize,
121 skipped_locked: usize,
122 skipped_unreadable: usize,
123 budget_exhausted: bool,
124}
125
126#[derive(Default)]
127struct TransientSearchCacheSweepSummary {
128 scanned: usize,
129 removed: usize,
130 bytes: u64,
131 skipped_fresh: usize,
132 skipped_unreadable: usize,
133 budget_exhausted: bool,
134}
135
136#[derive(Clone, Debug)]
137struct TransientSearchCacheName {
138 key: String,
139 pid: u32,
140}
141
142enum TransientSearchCacheWalk {
143 Complete(u64),
144 BudgetExceeded,
145 Failed,
146}
147
148pub(crate) const INTERACTIVE_ARTIFACT_READ_BUDGET: Duration = Duration::from_millis(250);
149pub(crate) const BORROWED_INDEX_LOAD_BUDGET: Duration = Duration::from_secs(1);
153pub(crate) const BORROWED_INDEX_LOAD_MAX_RECORDS: usize = 100_000;
154const BORROWED_INDEX_CHECKPOINT_INTERVAL: usize = 64;
155
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub(crate) enum BorrowedIndexLoadStop {
158 BudgetExceeded,
159 Cancelled,
160}
161
162#[derive(Debug)]
163pub(crate) enum BorrowedIndexLoad {
164 Loaded(SearchIndex, bool),
165 Stopped(BorrowedIndexLoadStop),
166 Invalid,
167}
168
169struct BorrowedIndexLoadBudget {
170 started_at: Instant,
171 duration: Duration,
172 max_records: usize,
173 stop: Cell<Option<BorrowedIndexLoadStop>>,
174}
175
176impl BorrowedIndexLoadBudget {
177 fn new(max_records: usize, duration: Duration) -> Self {
178 Self {
179 started_at: Instant::now(),
180 duration,
181 max_records,
182 stop: Cell::new(None),
183 }
184 }
185
186 fn preflight(&self, records: usize) -> bool {
187 if records > self.max_records {
188 self.stop.set(Some(BorrowedIndexLoadStop::BudgetExceeded));
189 return false;
190 }
191 self.checkpoint()
192 }
193
194 fn preflight_lookup_bytes(&self, bytes: u64, file_records: usize) -> bool {
195 let lookup_records = self.max_records.saturating_sub(file_records);
196 let max_bytes = 20u64.saturating_add(
197 u64::try_from(lookup_records)
198 .unwrap_or(u64::MAX)
199 .saturating_mul(LOOKUP_ENTRY_BYTES as u64),
200 );
201 if bytes > max_bytes {
202 self.stop.set(Some(BorrowedIndexLoadStop::BudgetExceeded));
203 return false;
204 }
205 self.checkpoint()
206 }
207
208 fn checkpoint_at(&self, record: usize) -> bool {
209 if record % BORROWED_INDEX_CHECKPOINT_INTERVAL == 0 {
210 self.checkpoint()
211 } else {
212 true
213 }
214 }
215
216 fn checkpoint(&self) -> bool {
217 if crate::executor::current_job_cancellation()
218 .is_some_and(|token| token.cancel_requested_before_commit())
219 {
220 self.stop.set(Some(BorrowedIndexLoadStop::Cancelled));
221 return false;
222 }
223 if self.started_at.elapsed() >= self.duration {
224 self.stop.set(Some(BorrowedIndexLoadStop::BudgetExceeded));
225 return false;
226 }
227 true
228 }
229}
230
231pub(crate) fn try_read_with_budget<T>(
237 lock: &RwLock<T>,
238 budget: Duration,
239) -> Option<RwLockReadGuard<'_, T>> {
240 let deadline = Instant::now() + budget;
241 loop {
242 match lock.try_read() {
243 Ok(guard) => return Some(guard),
244 Err(TryLockError::Poisoned(poisoned)) => return Some(poisoned.into_inner()),
245 Err(TryLockError::WouldBlock) => {
246 let now = Instant::now();
247 if now >= deadline {
248 return None;
249 }
250 std::thread::sleep((deadline - now).min(Duration::from_millis(1)));
251 }
252 }
253 }
254}
255
256pub struct CacheLock {
257 _guard: Option<fs_lock::LockGuard>,
258}
259
260impl CacheLock {
261 pub fn acquire(cache_dir: &Path, project_root: &Path) -> std::io::Result<Self> {
262 Self::acquire_with_timeout(cache_dir, project_root, Duration::from_secs(2))
263 }
264
265 pub fn try_acquire_for_shutdown(
266 cache_dir: &Path,
267 project_root: &Path,
268 ) -> std::io::Result<Self> {
269 Self::acquire_with_timeout(cache_dir, project_root, Duration::from_millis(25))
272 }
273
274 fn acquire_with_timeout(
275 cache_dir: &Path,
276 project_root: &Path,
277 timeout: Duration,
278 ) -> std::io::Result<Self> {
279 let path = cache_dir.join("cache.lock");
280 if !artifact_write_allowed(project_root, cache_dir, &path) {
281 return Ok(Self { _guard: None });
282 }
283 fs::create_dir_all(cache_dir)?;
284 let _acquire_guard = CACHE_LOCK_ACQUIRE_MUTEX
285 .lock()
286 .map_err(|_| std::io::Error::other("search cache lock acquisition mutex poisoned"))?;
287 fs_lock::try_acquire(&path, timeout)
288 .map(|guard| Self {
289 _guard: Some(guard),
290 })
291 .map_err(|error| match error {
292 fs_lock::AcquireError::Timeout => {
293 std::io::Error::other("timed out acquiring search cache lock")
294 }
295 fs_lock::AcquireError::Io(error) => error,
296 })
297 }
298}
299
300fn artifact_write_allowed(project_root: &Path, cache_dir: &Path, write_path: &Path) -> bool {
301 let artifact_key = cache_dir
302 .file_name()
303 .and_then(|name| name.to_str())
304 .unwrap_or_default();
305 crate::root_cache::ArtifactAccess::for_root(project_root).allows_write(artifact_key, write_path)
306}
307
308#[derive(Clone, Debug)]
309pub struct SearchIndex {
310 base: Option<Arc<BasePostings>>,
311 delta: Arc<DeltaState>,
312 delta_file_trigrams: HashMap<u32, Vec<u32>>,
315 pub files: Arc<Vec<FileEntry>>,
316 pub path_to_id: Arc<HashMap<PathBuf, u32>>,
317 pub ready: bool,
318 pub build_denied: bool,
324 project_root: PathBuf,
325 git_head: Option<String>,
326 max_file_size: u64,
327 ignore_rules_fingerprint: String,
328 pub file_trigram_count: Arc<Vec<u32>>,
329 unindexed_files: Arc<HashSet<u32>>,
330 base_file_count: u32,
331 delta_packed_bytes: usize,
332 compaction_state: Arc<Mutex<CompactionState>>,
333}
334
335#[derive(Clone, Debug, Default)]
338struct DeltaState {
339 postings: HashMap<u32, Vec<Posting>>,
340 superseded: HashSet<u32>,
341}
342
343#[derive(Clone, Debug)]
344struct BasePostings {
345 file: Arc<File>,
346 postings_blob_start: u64,
347 postings_blob_len: u64,
348 lookup: Arc<Vec<LookupEntry>>,
349}
350
351#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352struct LookupEntry {
353 trigram: u32,
354 offset: u64,
355 count: u32,
356}
357
358#[derive(Clone, Debug, Default)]
359struct CompactionState {
360 running: bool,
361 requested_again: bool,
362 buffered_paths: Vec<PathBuf>,
363}
364
365#[derive(Clone, Debug)]
366pub struct SearchIndexSnapshot {
367 base: Option<Arc<BasePostings>>,
368 delta: Arc<DeltaState>,
369 files: Arc<Vec<FileEntry>>,
370 path_to_id: Arc<HashMap<PathBuf, u32>>,
371 ready: bool,
372 project_root: PathBuf,
373 max_file_size: u64,
374 file_trigram_count: Arc<Vec<u32>>,
375 unindexed_files: Arc<HashSet<u32>>,
376}
377
378#[derive(Clone, Debug, Default)]
379pub struct LexicalRankResult {
380 pub files: Vec<(PathBuf, f32)>,
381 pub engine_capped: bool,
382}
383
384impl SearchIndex {
385 pub fn file_count(&self) -> usize {
387 self.files.len()
388 }
389
390 pub fn trigram_count(&self) -> usize {
392 let base_count = self.base.as_ref().map_or(0, |base| base.lookup.len());
393 let Some(base) = &self.base else {
394 return self.delta.postings.len();
395 };
396 base_count
397 + self
398 .delta
399 .postings
400 .keys()
401 .filter(|trigram| base.lookup_entry(**trigram).is_none())
402 .count()
403 }
404
405 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
409 let Ok(compaction) = self.compaction_state.try_lock() else {
410 return crate::memory::MemoryEstimate::busy();
411 };
412 if self.base.is_none()
413 && self.delta.postings.is_empty()
414 && self.delta_file_trigrams.is_empty()
415 && self.files.is_empty()
416 && self.path_to_id.is_empty()
417 && self.file_trigram_count.is_empty()
418 && self.unindexed_files.is_empty()
419 && self.delta.superseded.is_empty()
420 && compaction.buffered_paths.is_empty()
421 {
422 return crate::memory::MemoryEstimate::estimated(0)
423 .count("files", 0)
424 .count("delta_trigrams", 0)
425 .count("delta_postings", 0)
426 .count("superseded_files", 0)
427 .count("unindexed_files", 0)
428 .count("base_lookup_entries", 0)
429 .count_u64("delta_packed_bytes", 0)
430 .count_u64("base_postings_resident_bytes", 0);
431 }
432 let delta_posting_count = self
433 .delta
434 .postings
435 .values()
436 .map(Vec::len)
437 .fold(0usize, usize::saturating_add);
438 let delta_postings_bytes = crate::memory::usize_to_u64(delta_posting_count)
439 .saturating_mul(std::mem::size_of::<Posting>() as u64)
440 .saturating_add(
441 crate::memory::usize_to_u64(self.delta.postings.len())
442 .saturating_mul(std::mem::size_of::<u32>() as u64),
443 );
444 let delta_file_trigram_count = self
445 .delta_file_trigrams
446 .values()
447 .map(Vec::len)
448 .fold(0usize, usize::saturating_add);
449 let delta_file_table_bytes = crate::memory::usize_to_u64(delta_file_trigram_count)
450 .saturating_mul(std::mem::size_of::<u32>() as u64)
451 .saturating_add(
452 crate::memory::usize_to_u64(self.delta_file_trigrams.len())
453 .saturating_mul(std::mem::size_of::<u32>() as u64),
454 );
455 let files_bytes = crate::memory::usize_to_u64(self.files.len())
456 .saturating_mul(std::mem::size_of::<FileEntry>() as u64)
457 .saturating_add(
458 self.files
459 .iter()
460 .map(|entry| crate::memory::path_bytes(&entry.path))
461 .fold(0u64, u64::saturating_add),
462 );
463 let path_table_bytes = self.path_to_id.iter().fold(0u64, |bytes, (path, _)| {
464 bytes
465 .saturating_add(std::mem::size_of::<u32>() as u64)
466 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
467 .saturating_add(crate::memory::path_bytes(path))
468 });
469 let file_count_table_bytes = crate::memory::usize_to_u64(self.file_trigram_count.len())
470 .saturating_mul(std::mem::size_of::<u32>() as u64);
471 let masks_bytes = crate::memory::usize_to_u64(
472 self.delta
473 .superseded
474 .len()
475 .saturating_add(self.unindexed_files.len()),
476 )
477 .saturating_mul(std::mem::size_of::<u32>() as u64);
478 let base_lookup_bytes = self
479 .base
480 .as_ref()
481 .map(|base| {
482 crate::memory::usize_to_u64(base.lookup.len())
483 .saturating_mul(std::mem::size_of::<LookupEntry>() as u64)
484 })
485 .unwrap_or(0);
486 let compaction_bytes = compaction
487 .buffered_paths
488 .iter()
489 .map(|path| {
490 (std::mem::size_of::<PathBuf>() as u64)
491 .saturating_add(crate::memory::path_bytes(path))
492 })
493 .fold(0u64, u64::saturating_add);
494 let metadata_bytes = crate::memory::path_bytes(&self.project_root)
495 .saturating_add(
496 self.git_head
497 .as_ref()
498 .map(|head| crate::memory::usize_to_u64(head.len()))
499 .unwrap_or(0),
500 )
501 .saturating_add(crate::memory::usize_to_u64(
502 self.ignore_rules_fingerprint.len(),
503 ));
504 let estimated_bytes = delta_postings_bytes
505 .saturating_add(delta_file_table_bytes)
506 .saturating_add(files_bytes)
507 .saturating_add(path_table_bytes)
508 .saturating_add(file_count_table_bytes)
509 .saturating_add(masks_bytes)
510 .saturating_add(base_lookup_bytes)
511 .saturating_add(compaction_bytes)
512 .saturating_add(metadata_bytes);
513 crate::memory::MemoryEstimate::estimated(estimated_bytes)
514 .count("files", self.files.len())
515 .count("delta_trigrams", self.delta.postings.len())
516 .count("delta_postings", delta_posting_count)
517 .count("superseded_files", self.delta.superseded.len())
518 .count("unindexed_files", self.unindexed_files.len())
519 .count(
520 "base_lookup_entries",
521 self.base
522 .as_ref()
523 .map(|base| base.lookup.len())
524 .unwrap_or(0),
525 )
526 .count_u64("delta_packed_bytes", self.delta_packed_bytes as u64)
527 .count_u64("base_postings_resident_bytes", 0)
528 }
529
530 pub(crate) fn has_pending_disk_changes(&self) -> bool {
534 !self.delta.postings.is_empty()
535 || !self.delta.superseded.is_empty()
536 || self.path_to_id.len() != self.base_file_count as usize
537 }
538
539 pub fn snapshot(&self) -> SearchIndexSnapshot {
544 SearchIndexSnapshot {
545 base: self.base.clone(),
546 delta: Arc::clone(&self.delta),
547 files: Arc::clone(&self.files),
548 path_to_id: Arc::clone(&self.path_to_id),
549 ready: self.ready,
550 project_root: self.project_root.clone(),
551 max_file_size: self.max_file_size,
552 file_trigram_count: Arc::clone(&self.file_trigram_count),
553 unindexed_files: Arc::clone(&self.unindexed_files),
554 }
555 }
556
557 pub fn query_trigrams_from_tokens(tokens: &[&str]) -> Vec<u32> {
559 query_trigrams_from_tokens(tokens)
560 }
561
562 pub fn lexical_rank(
564 &self,
565 query_trigrams: &[u32],
566 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
567 max_files: usize,
568 ) -> Vec<(PathBuf, f32)> {
569 self.snapshot()
570 .lexical_rank_with_stats(query_trigrams, candidate_filter, max_files)
571 .files
572 }
573
574 pub fn is_ready(&self) -> bool {
575 self.ready
576 }
577
578 pub fn lexical_rank_at_depth(
580 &self,
581 query_trigrams: &[u32],
582 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
583 depth: usize,
584 ) -> LexicalRankResult {
585 self.snapshot()
586 .lexical_rank_at_depth(query_trigrams, candidate_filter, depth)
587 }
588
589 pub fn lexical_discovery_and_rank_at_depth(
591 &self,
592 query_trigrams: &[u32],
593 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
594 depth: usize,
595 ) -> LexicalRankResult {
596 self.snapshot()
597 .lexical_rank_at_depth(query_trigrams, candidate_filter, depth)
598 }
599
600 pub fn whole_corpus_exact_pass(
602 &self,
603 query: &str,
604 search_root: &Path,
605 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
606 ) -> Vec<ExactPassMatch> {
607 self.snapshot()
608 .whole_corpus_exact_pass(query, search_root, candidate_filter)
609 }
610
611 pub fn lexical_rank_with_stats(
614 &self,
615 query_trigrams: &[u32],
616 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
617 max_files: usize,
618 ) -> LexicalRankResult {
619 self.snapshot()
620 .lexical_rank_with_stats(query_trigrams, candidate_filter, max_files)
621 }
622}
623
624impl SearchIndexSnapshot {
625 pub fn trigram_count(&self) -> usize {
627 let base_count = self.base.as_ref().map_or(0, |base| base.lookup.len());
628 let Some(base) = &self.base else {
629 return self.delta.postings.len();
630 };
631 base_count
632 + self
633 .delta
634 .postings
635 .keys()
636 .filter(|trigram| base.lookup_entry(**trigram).is_none())
637 .count()
638 }
639
640 pub(crate) fn has_file_in_scope(&self, search_root: &Path) -> bool {
641 let search_root = canonicalize_for_search_membership(search_root);
642 self.files.iter().any(|file| {
643 !file.path.as_os_str().is_empty() && is_within_search_root(&search_root, &file.path)
644 })
645 }
646
647 pub fn lexical_rank_with_stats(
650 &self,
651 query_trigrams: &[u32],
652 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
653 max_files: usize,
654 ) -> LexicalRankResult {
655 if query_trigrams.is_empty() || max_files == 0 {
656 return LexicalRankResult::default();
657 }
658
659 let mut non_zero: Vec<(u32, usize)> = query_trigrams
660 .iter()
661 .filter_map(|trigram| {
662 let posting_count = self.posting_count(*trigram);
663 (posting_count > 0).then_some((*trigram, posting_count))
664 })
665 .collect();
666 if non_zero.is_empty() {
667 return LexicalRankResult::default();
668 }
669
670 non_zero.sort_unstable_by_key(|(_, posting_count)| *posting_count);
671 let selected_count = non_zero.len().min(3);
672 let candidate_cap = if selected_count == 3 { 200 } else { 500 };
673
674 let postings_by_trigram = materialize_query_postings(self, query_trigrams);
679 let mut candidate_ids = BTreeSet::new();
680 for (trigram, _) in non_zero.iter().take(selected_count) {
681 if let Some(postings) = postings_by_trigram.get(trigram) {
682 candidate_ids.extend(postings.iter().copied());
683 }
684 }
685 let pre_filter_candidate_count = candidate_ids.len();
686 let engine_capped = pre_filter_candidate_count > candidate_cap;
687 let filtered_candidates = candidate_ids
688 .into_iter()
689 .filter_map(|file_id| {
690 self.files
691 .get(file_id as usize)
692 .map(|entry| (file_id, entry))
693 })
694 .filter(|(_, entry)| {
695 if let Some(filter) = candidate_filter {
696 filter(&entry.path)
697 } else {
698 true
699 }
700 })
701 .collect::<Vec<_>>();
702
703 let mut ranked = Vec::new();
704 for (file_id, entry) in filtered_candidates.into_iter().take(candidate_cap) {
705 let score =
706 lexical_score_from_postings(self, query_trigrams, &postings_by_trigram, file_id);
707 if score > 0.0 {
708 ranked.push((entry.path.clone(), score));
709 }
710 }
711
712 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
713 ranked.truncate(max_files);
714 LexicalRankResult {
715 files: ranked,
716 engine_capped,
717 }
718 }
719
720 pub fn lexical_rank_at_depth(
722 &self,
723 query_trigrams: &[u32],
724 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
725 depth: usize,
726 ) -> LexicalRankResult {
727 if query_trigrams.is_empty() || depth == 0 {
728 return LexicalRankResult::default();
729 }
730
731 let mut non_zero: Vec<(u32, usize)> = query_trigrams
732 .iter()
733 .filter_map(|trigram| {
734 let posting_count = self.posting_count(*trigram);
735 (posting_count > 0).then_some((*trigram, posting_count))
736 })
737 .collect();
738 if non_zero.is_empty() {
739 return LexicalRankResult::default();
740 }
741
742 non_zero.sort_unstable_by_key(|(_, posting_count)| *posting_count);
743 let postings_by_trigram = materialize_query_postings(self, query_trigrams);
744
745 let initial_selected = non_zero.len().min(3);
746 let mut candidate_ids = BTreeSet::new();
747 for (trigram, _) in non_zero.iter().take(initial_selected) {
748 if let Some(postings) = postings_by_trigram.get(trigram) {
749 candidate_ids.extend(postings.iter().copied());
750 }
751 }
752 if candidate_ids.len() < depth {
753 for (trigram, _) in non_zero.iter().skip(initial_selected) {
754 if candidate_ids.len() >= depth {
755 break;
756 }
757 if let Some(postings) = postings_by_trigram.get(trigram) {
758 candidate_ids.extend(postings.iter().copied());
759 }
760 }
761 }
762
763 let candidate_cap = depth;
764 let pre_filter_candidate_count = candidate_ids.len();
765 let engine_capped = pre_filter_candidate_count > candidate_cap;
766
767 let filtered_candidates = candidate_ids
768 .into_iter()
769 .filter_map(|file_id| {
770 self.files
771 .get(file_id as usize)
772 .map(|entry| (file_id, entry))
773 })
774 .filter(|(_, entry)| {
775 if let Some(filter) = candidate_filter {
776 filter(&entry.path)
777 } else {
778 true
779 }
780 })
781 .collect::<Vec<_>>();
782
783 let mut ranked = Vec::new();
784 for (file_id, entry) in filtered_candidates.into_iter().take(candidate_cap) {
785 let score =
786 lexical_score_from_postings(self, query_trigrams, &postings_by_trigram, file_id);
787 if score > 0.0 {
788 ranked.push((entry.path.clone(), score));
789 }
790 }
791
792 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
793 ranked.truncate(depth);
794 LexicalRankResult {
795 files: ranked,
796 engine_capped,
797 }
798 }
799
800 pub fn lexical_discovery_and_rank_at_depth(
802 &self,
803 query_trigrams: &[u32],
804 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
805 depth: usize,
806 ) -> LexicalRankResult {
807 self.lexical_rank_at_depth(query_trigrams, candidate_filter, depth)
808 }
809
810 pub fn whole_corpus_exact_pass(
814 &self,
815 query: &str,
816 search_root: &Path,
817 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
818 ) -> Vec<ExactPassMatch> {
819 let phrase = exact_lane::exact_phrase(query);
820 let norm_phrase = exact_lane::normalize_exact_phrase(phrase);
821 let content_tokens = crate::query_shape::extract_content_tokens(query);
822
823 let mut candidate_file_ids = BTreeSet::new();
824
825 if phrase.len() >= 3 {
827 let phrase_query = decompose_regex(®ex::escape(phrase));
828 candidate_file_ids.extend(self.candidates(&phrase_query));
829 }
830
831 if content_tokens.len() >= 2 {
833 for token in &content_tokens {
834 if token.len() >= 3 {
835 let token_query = decompose_regex(®ex::escape(token));
836 candidate_file_ids.extend(self.candidates(&token_query));
837 }
838 }
839 }
840
841 if candidate_file_ids.is_empty() && (phrase.len() < 3 || content_tokens.is_empty()) {
843 candidate_file_ids.extend(self.active_file_ids());
844 }
845
846 let mut matches = Vec::new();
847 let search_root_canon = canonicalize_for_search_membership(search_root);
848
849 for file_id in candidate_file_ids {
850 let Some(file_entry) = self.files.get(file_id as usize) else {
851 continue;
852 };
853 if file_entry.path.as_os_str().is_empty() {
854 continue;
855 }
856
857 let file_canon = canonicalize_for_search_membership(&file_entry.path);
858 if !is_within_search_root(search_root, &file_entry.path)
859 && !is_within_search_root(&search_root_canon, &file_canon)
860 && !is_within_search_root(&search_root_canon, &file_entry.path)
861 {
862 continue;
863 }
864
865 if let Some(filter) = candidate_filter {
866 if !filter(&file_entry.path) {
867 continue;
868 }
869 }
870
871 let SearchCorpusEligibility::Eligible(file) =
872 read_search_corpus_file(&file_entry.path, self.max_file_size)
873 else {
874 continue;
875 };
876 let digest = blake3::hash(&file.bytes).to_hex().to_string();
877 let text = String::from_utf8_lossy(&file.bytes);
878
879 if let Some(candidates) = exact_lane::verify_exact_matches_in_text(
880 &file_entry.path,
881 &text,
882 &norm_phrase,
883 &content_tokens,
884 ) {
885 for mut cand in candidates {
886 cand.evidence.generated = file.generated;
887 matches.push(ExactPassMatch {
888 path: cand.path,
889 symbol_range: cand.symbol_range,
890 evidence: cand.evidence,
891 content_digest: digest.clone(),
892 });
893 }
894 }
895 }
896
897 matches
898 }
899}
900
901#[derive(Clone, Debug, PartialEq, Eq)]
902pub struct Posting {
903 pub file_id: u32,
904 pub next_mask: u8,
905 pub loc_mask: u8,
906}
907
908#[derive(Clone, Debug)]
909pub struct FileEntry {
910 pub path: PathBuf,
911 pub size: u64,
912 pub modified: SystemTime,
913 pub content_hash: blake3::Hash,
914}
915
916#[derive(Clone, Debug, PartialEq, Eq)]
917pub struct GrepMatch {
918 pub file: PathBuf,
919 pub line: u32,
920 pub column: u32,
921 pub line_text: String,
922 pub match_text: String,
923}
924
925#[derive(Clone, Debug)]
926pub struct GrepResult {
927 pub matches: Vec<GrepMatch>,
928 pub total_matches: usize,
929 pub files_searched: usize,
930 pub files_with_matches: usize,
931 pub index_status: IndexStatus,
932 pub truncated: bool,
933 pub fully_degraded: bool,
934 pub engine_capped: bool,
935 pub walk_truncated: bool,
937 pub skipped_foreign_mounts: usize,
939}
940
941#[derive(Clone, Copy, Debug, Default)]
942pub(crate) struct GrepQueryPhaseTimings {
943 pub trigram_lookup: Duration,
944 pub pread_verify: Duration,
945 pub post_filter: Duration,
946 pub candidate_count: usize,
947 pub bytes_verified: usize,
948}
949
950#[derive(Clone, Copy, Debug, PartialEq, Eq)]
951pub enum IndexStatus {
952 Ready,
953 Building,
954 Fallback,
955 Disabled,
956}
957
958impl IndexStatus {
959 pub fn as_str(&self) -> &'static str {
960 match self {
961 IndexStatus::Ready => "Ready",
962 IndexStatus::Building => "Building",
963 IndexStatus::Fallback => "Fallback",
964 IndexStatus::Disabled => "Disabled",
965 }
966 }
967}
968
969#[derive(Clone, Debug, Default)]
970pub struct RegexQuery {
971 pub and_trigrams: Vec<u32>,
972 pub or_groups: Vec<Vec<u32>>,
973 pub(crate) and_filters: HashMap<u32, PostingFilter>,
974 pub(crate) or_filters: Vec<HashMap<u32, PostingFilter>>,
975}
976
977#[derive(Clone, Copy, Debug, Default)]
978pub(crate) struct PostingFilter {
979 next_mask: u8,
980 loc_mask: u8,
981}
982
983#[derive(Clone, Copy)]
984pub(crate) struct SearchFileMetadata {
985 size: u64,
986 modified: SystemTime,
987}
988
989struct PreparedIndexedFile {
990 metadata: SearchFileMetadata,
991 content_hash: blake3::Hash,
992 trigram_map: BTreeMap<u32, PostingFilter>,
993}
994
995pub(crate) struct SearchCorpusFile {
996 metadata: SearchFileMetadata,
997 pub(crate) bytes: Vec<u8>,
998 pub(crate) generated: bool,
999}
1000
1001pub(crate) enum SearchCorpusEligibility {
1002 Eligible(SearchCorpusFile),
1003 Unindexed(SearchFileMetadata),
1004 Skipped,
1005}
1006
1007enum PreparedSearchPath {
1008 Indexed(PreparedIndexedFile),
1009 Unindexed(SearchFileMetadata),
1010 Skipped,
1011}
1012
1013#[derive(Clone, Debug, Default)]
1014struct QueryBuild {
1015 and_runs: Vec<Vec<u8>>,
1016 or_groups: Vec<Vec<Vec<u8>>>,
1017}
1018
1019pub type GrepPathExclusion = fn(&Path, &Path) -> bool;
1020
1021#[derive(Clone, Debug, Default)]
1022pub(crate) struct PathFilters {
1023 includes: Option<GlobSet>,
1024 excludes: Option<GlobSet>,
1025}
1026
1027#[derive(Clone, Debug)]
1028pub(crate) struct SearchScope {
1029 pub root: PathBuf,
1030 pub use_index: bool,
1031}
1032
1033#[derive(Clone, Debug)]
1034struct SharedGrepMatch {
1035 file: Arc<PathBuf>,
1036 line: u32,
1037 column: u32,
1038 line_text: String,
1039 match_text: String,
1040}
1041
1042#[derive(Clone, Debug)]
1043enum SearchMatcher {
1044 Literal(LiteralSearch),
1045 Regex(Regex),
1046}
1047
1048#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1049enum IgnoreRulesLoadPolicy {
1050 Strict,
1051 BorrowTolerant,
1052}
1053
1054impl SearchIndex {
1055 pub fn new() -> Self {
1056 SearchIndex {
1057 base: None,
1058 delta: Arc::new(DeltaState::default()),
1059 delta_file_trigrams: HashMap::new(),
1060 files: Arc::new(Vec::new()),
1061 path_to_id: Arc::new(HashMap::new()),
1062 ready: false,
1063 build_denied: false,
1064 project_root: PathBuf::new(),
1065 git_head: None,
1066 max_file_size: DEFAULT_MAX_FILE_SIZE,
1067 ignore_rules_fingerprint: String::new(),
1068 file_trigram_count: Arc::new(Vec::new()),
1069 unindexed_files: Arc::new(HashSet::new()),
1070 base_file_count: 0,
1071 delta_packed_bytes: 0,
1072 compaction_state: Arc::new(Mutex::new(CompactionState::default())),
1073 }
1074 }
1075
1076 pub fn build(root: &Path) -> Self {
1077 Self::build_with_limit(root, DEFAULT_MAX_FILE_SIZE)
1078 }
1079
1080 pub fn build_with_limit(root: &Path, max_file_size: u64) -> Self {
1081 let started = Instant::now();
1082 let cache_dir = transient_search_cache_dir(root);
1083 let build_lock = transient_search_cache_build_lock(&cache_dir);
1089 let _build_guard = build_lock
1090 .lock()
1091 .unwrap_or_else(std::sync::PoisonError::into_inner);
1092 if let Err(error) = truncate_transient_search_cache_dir(&cache_dir) {
1093 log::warn!(
1094 "search index: could not reset transient cache {} ({}); falling back to bounded in-memory delta",
1095 cache_dir.display(),
1096 error
1097 );
1098 return Self::build_in_memory(root, max_file_size, started);
1099 }
1100 Self::build_with_limit_to_cache_dir(root, max_file_size, &cache_dir)
1101 }
1102
1103 pub fn build_with_limit_to_cache_dir(
1104 root: &Path,
1105 max_file_size: u64,
1106 cache_dir: &Path,
1107 ) -> Self {
1108 let started = std::time::Instant::now();
1109 let key = artifact_cache_key(root);
1110 let scope =
1111 crate::logging::IndexBuildScope::new(crate::logging::IndexPlane::Search, root, key);
1112 let _index_build = crate::logging::install_index_build(scope.clone());
1113 let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
1114 crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
1115 crate::logging::IndexEventKind::BuildStarted,
1116 &scope,
1117 ));
1118 if !artifact_write_allowed(root, cache_dir, &cache_dir.join("cache.bin")) {
1119 crate::slog_info!(
1125 "search index cold build denied: {} may not write the cache artifact at {}; reporting build-denied instead of building",
1126 root.display(),
1127 cache_dir.display()
1128 );
1129 crate::logging::log_index_event(
1130 crate::logging::IndexEvent::from_scope(
1131 crate::logging::IndexEventKind::BuildFailed,
1132 &scope,
1133 )
1134 .field("reason", "denied"),
1135 );
1136 failure_guard.disarm();
1137 let mut index = Self::new();
1138 index.project_root = root.to_path_buf();
1139 index.max_file_size = max_file_size;
1140 index.build_denied = true;
1141 return index;
1142 }
1143 crate::logging::log_index_event(
1144 crate::logging::IndexEvent::from_scope(
1145 crate::logging::IndexEventKind::BuildProgress,
1146 &scope,
1147 )
1148 .field("stage", "streaming")
1149 .field("completed", 0)
1150 .field("total", 1)
1151 .field("elapsed_ms", scope.elapsed_ms()),
1152 );
1153 match build_streaming_index(root, max_file_size, cache_dir) {
1154 Ok((mut index, indexed)) => {
1155 index.ready = true;
1156 crate::slog_info!(
1157 "search index cold streaming build: {} files, {} trigrams, {} ms (pool={})",
1158 indexed,
1159 index.trigram_count(),
1160 started.elapsed().as_millis(),
1161 search_index_build_pool_size()
1162 );
1163 crate::logging::log_index_event(
1164 crate::logging::IndexEvent::from_scope(
1165 crate::logging::IndexEventKind::BuildProgress,
1166 &scope,
1167 )
1168 .field("stage", "streaming")
1169 .field("completed", 1)
1170 .field("total", 1)
1171 .field("elapsed_ms", scope.elapsed_ms()),
1172 );
1173 crate::logging::log_index_event(
1174 crate::logging::IndexEvent::from_scope(
1175 crate::logging::IndexEventKind::BuildReady,
1176 &scope,
1177 )
1178 .field("elapsed_ms", scope.elapsed_ms())
1179 .field("files", indexed)
1180 .field("trigrams", index.trigram_count()),
1181 );
1182 failure_guard.disarm();
1183 index
1184 }
1185 Err(error) => {
1186 log::warn!(
1187 "search index: streaming build failed ({}); falling back to bounded in-memory delta",
1188 error
1189 );
1190 crate::logging::log_index_event(
1191 crate::logging::IndexEvent::from_scope(
1192 crate::logging::IndexEventKind::BuildProgress,
1193 &scope,
1194 )
1195 .field("stage", "fallback")
1196 .field("completed", 0)
1197 .field("total", 1)
1198 .field("elapsed_ms", scope.elapsed_ms()),
1199 );
1200 let index = Self::build_in_memory(root, max_file_size, started);
1201 crate::logging::log_index_event(
1202 crate::logging::IndexEvent::from_scope(
1203 crate::logging::IndexEventKind::BuildReady,
1204 &scope,
1205 )
1206 .field("elapsed_ms", scope.elapsed_ms())
1207 .field("files", index.files.len())
1208 .field("trigrams", index.trigram_count()),
1209 );
1210 failure_guard.disarm();
1211 index
1212 }
1213 }
1214 }
1215
1216 fn build_in_memory(root: &Path, max_file_size: u64, started: Instant) -> Self {
1217 let project_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1218 let mut index = SearchIndex {
1219 project_root: project_root.clone(),
1220 max_file_size,
1221 ignore_rules_fingerprint: ignore_rules_fingerprint(&project_root),
1222 ..SearchIndex::new()
1223 };
1224 let filters = PathFilters::default();
1225 let paths: Vec<PathBuf> = walk_project_files(&index.project_root, &filters);
1226 let indexed = index.ingest_paths_parallel(&paths);
1227 index.git_head = current_git_head(&index.project_root);
1228 index.ready = true;
1229 crate::slog_info!(
1230 "search index fallback build: {} files, {} trigrams, {} ms (pool={})",
1231 indexed,
1232 index.trigram_count(),
1233 started.elapsed().as_millis(),
1234 search_index_build_pool_size()
1235 );
1236 index
1237 }
1238
1239 #[cfg(test)]
1241 pub fn build_with_limit_serial(root: &Path, max_file_size: u64) -> Self {
1242 let project_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1243 let mut index = SearchIndex {
1244 project_root: project_root.clone(),
1245 max_file_size,
1246 ignore_rules_fingerprint: ignore_rules_fingerprint(&project_root),
1247 ..SearchIndex::new()
1248 };
1249 let filters = PathFilters::default();
1250 for path in walk_project_files(&project_root, &filters) {
1251 index.update_file(&path);
1252 }
1253 index.git_head = current_git_head(&project_root);
1254 index.ready = true;
1255 index
1256 }
1257
1258 fn ingest_paths_parallel(&mut self, paths: &[PathBuf]) -> usize {
1259 let max_file_size = self.max_file_size;
1260 let pool_size = search_index_build_pool_size();
1261 let chunk_size = pool_size.saturating_mul(4).clamp(1, 32);
1262 let pool = match rayon::ThreadPoolBuilder::new()
1263 .num_threads(pool_size)
1264 .thread_name(|index| format!("aft-search-build-{index}"))
1265 .stack_size(8 * 1024 * 1024)
1266 .build()
1267 {
1268 Ok(pool) => Some(pool),
1269 Err(error) => {
1270 log::warn!(
1271 "search index: bounded build pool unavailable ({error}); using global pool"
1272 );
1273 None
1274 }
1275 };
1276
1277 let mut indexed = 0usize;
1278 for chunk in paths.chunks(chunk_size) {
1279 let prepare_chunk = || -> Vec<PreparedSearchPath> {
1280 chunk
1281 .par_iter()
1282 .map(|path| prepare_search_path(path, max_file_size))
1283 .collect()
1284 };
1285 let prepared = match &pool {
1286 Some(pool) => pool.install(prepare_chunk),
1287 None => prepare_chunk(),
1288 };
1289
1290 for (path, prepared) in chunk.iter().zip(prepared) {
1291 let inserted = match prepared {
1292 PreparedSearchPath::Indexed(file) => self.index_prepared_new_file(path, file),
1293 PreparedSearchPath::Unindexed(metadata) => {
1294 self.track_unindexed_file_with_metadata(path, metadata)
1295 }
1296 PreparedSearchPath::Skipped => false,
1297 };
1298 if inserted {
1299 indexed += 1;
1300 }
1301 }
1302 }
1303
1304 indexed
1305 }
1306
1307 pub fn index_file(&mut self, path: &Path, content: &[u8]) {
1308 self.remove_file(path);
1309 let metadata = metadata_for_indexed_content(path, content.len() as u64);
1310 self.index_file_with_metadata(path, content, metadata);
1311 }
1312
1313 fn index_file_with_metadata(
1314 &mut self,
1315 path: &Path,
1316 content: &[u8],
1317 metadata: SearchFileMetadata,
1318 ) -> bool {
1319 self.index_prepared_new_file(
1320 path,
1321 PreparedIndexedFile {
1322 metadata,
1323 content_hash: cache_freshness::hash_bytes(content),
1324 trigram_map: trigram_filter_map(content, true),
1325 },
1326 )
1327 }
1328
1329 fn index_prepared_new_file(&mut self, path: &Path, file: PreparedIndexedFile) -> bool {
1330 let file_id = match self.allocate_file_id_with_metadata(path, file.metadata) {
1331 Some(file_id) => file_id,
1332 None => return false,
1333 };
1334 if let Some(entry) = Arc::make_mut(&mut self.files).get_mut(file_id as usize) {
1335 entry.content_hash = file.content_hash;
1336 }
1337
1338 let mut file_trigrams = Vec::with_capacity(file.trigram_map.len());
1339 let delta = Arc::make_mut(&mut self.delta);
1340 for (trigram, filter) in file.trigram_map {
1341 let postings = delta.postings.entry(trigram).or_default();
1342 insert_delta_posting(
1343 postings,
1344 Posting {
1345 file_id,
1346 next_mask: filter.next_mask,
1347 loc_mask: filter.loc_mask,
1348 },
1349 );
1350 file_trigrams.push(trigram);
1351 }
1352
1353 let trigram_count = file_trigrams.len() as u32;
1354 self.delta_packed_bytes = self
1355 .delta_packed_bytes
1356 .saturating_add(file_trigrams.len().saturating_mul(POSTING_BYTES));
1357 self.delta_file_trigrams.insert(file_id, file_trigrams);
1358 ensure_count_slot(Arc::make_mut(&mut self.file_trigram_count), file_id);
1359 if let Some(count) = Arc::make_mut(&mut self.file_trigram_count).get_mut(file_id as usize) {
1360 *count = trigram_count;
1361 }
1362 Arc::make_mut(&mut self.unindexed_files).remove(&file_id);
1363 self.update_compaction_flags(Some(path));
1364 true
1365 }
1366
1367 pub fn remove_file(&mut self, path: &Path) {
1368 let mut canonical_parents = ParentCanonicalizationMemo::default();
1369 self.remove_file_with_canonicalization_memo(path, &mut canonical_parents);
1370 }
1371
1372 fn remove_file_with_canonicalization_memo(
1373 &mut self,
1374 path: &Path,
1375 canonical_parents: &mut ParentCanonicalizationMemo,
1376 ) {
1377 let canonical_path =
1378 canonicalize_existing_or_deleted_path_with_memo(path, canonical_parents);
1379 let file_id = {
1380 let path_to_id = Arc::make_mut(&mut self.path_to_id);
1381 if let Some(file_id) = path_to_id.remove(path) {
1382 file_id
1383 } else if canonical_path.as_path() != path {
1384 let Some(file_id) = path_to_id.remove(&canonical_path) else {
1385 return;
1386 };
1387 file_id
1388 } else {
1389 return;
1390 }
1391 };
1392
1393 if file_id < self.base_file_count {
1394 Arc::make_mut(&mut self.delta).superseded.insert(file_id);
1395 }
1396
1397 if let Some(trigrams) = self.delta_file_trigrams.remove(&file_id) {
1398 self.delta_packed_bytes = self
1399 .delta_packed_bytes
1400 .saturating_sub(trigrams.len().saturating_mul(POSTING_BYTES));
1401 let delta = Arc::make_mut(&mut self.delta);
1402 for trigram in trigrams {
1403 let should_remove = if let Some(postings) = delta.postings.get_mut(&trigram) {
1404 postings.retain(|posting| posting.file_id != file_id);
1405 postings.is_empty()
1406 } else {
1407 false
1408 };
1409
1410 if should_remove {
1411 delta.postings.remove(&trigram);
1412 }
1413 }
1414 }
1415
1416 Arc::make_mut(&mut self.unindexed_files).remove(&file_id);
1417 if let Some(file) = Arc::make_mut(&mut self.files).get_mut(file_id as usize) {
1418 file.path = PathBuf::new();
1419 file.size = 0;
1420 file.modified = UNIX_EPOCH;
1421 file.content_hash = cache_freshness::zero_hash();
1422 }
1423 if let Some(count) = Arc::make_mut(&mut self.file_trigram_count).get_mut(file_id as usize) {
1424 *count = 0;
1425 }
1426 self.update_compaction_flags(Some(path));
1427 }
1428
1429 pub fn update_file(&mut self, path: &Path) {
1430 let mut canonical_parents = ParentCanonicalizationMemo::default();
1431 self.update_file_with_canonicalization_memo(path, &mut canonical_parents);
1432 }
1433
1434 fn update_file_with_canonicalization_memo(
1435 &mut self,
1436 path: &Path,
1437 canonical_parents: &mut ParentCanonicalizationMemo,
1438 ) {
1439 let canonical_path =
1443 canonicalize_existing_or_deleted_path_with_memo(path, canonical_parents);
1444 self.remove_file_with_canonicalization_memo(&canonical_path, canonical_parents);
1445
1446 let metadata = match fs::metadata(&canonical_path) {
1447 Ok(metadata) if metadata.is_file() => metadata,
1448 _ => return,
1449 };
1450
1451 let metadata = search_file_metadata(&metadata);
1452
1453 if is_binary_path(&canonical_path, metadata.size) {
1454 self.track_unindexed_file_with_metadata(&canonical_path, metadata);
1455 return;
1456 }
1457
1458 if metadata.size > self.max_file_size {
1459 self.track_unindexed_file_with_metadata(&canonical_path, metadata);
1460 return;
1461 }
1462
1463 let content = match fs::read(&canonical_path) {
1464 Ok(content) => content,
1465 Err(_) => return,
1466 };
1467
1468 if is_binary_bytes(&content) {
1469 self.track_unindexed_file_with_metadata(&canonical_path, metadata);
1470 return;
1471 }
1472
1473 self.index_file_with_metadata(&canonical_path, &content, metadata);
1474 }
1475
1476 pub fn grep(
1477 &self,
1478 pattern: &str,
1479 case_sensitive: bool,
1480 include: &[String],
1481 exclude: &[String],
1482 search_root: &Path,
1483 max_results: usize,
1484 ) -> GrepResult {
1485 self.snapshot().grep(
1486 pattern,
1487 case_sensitive,
1488 include,
1489 exclude,
1490 search_root,
1491 max_results,
1492 )
1493 }
1494
1495 pub fn search_grep(
1496 &self,
1497 pattern: &CompiledPattern,
1498 include: &[String],
1499 exclude: &[String],
1500 search_root: &Path,
1501 max_results: usize,
1502 ) -> GrepResult {
1503 self.snapshot()
1504 .search_grep(pattern, include, exclude, search_root, max_results)
1505 }
1506
1507 pub fn glob(&self, pattern: &str, search_root: &Path) -> Vec<PathBuf> {
1508 self.snapshot().glob(pattern, search_root)
1509 }
1510
1511 pub fn candidates(&self, query: &RegexQuery) -> Vec<u32> {
1512 self.snapshot().candidates(query)
1513 }
1514
1515 pub fn write_to_disk(&mut self, cache_dir: &Path, git_head: Option<&str>) -> bool {
1521 if !artifact_write_allowed(&self.project_root, cache_dir, &cache_dir.join("cache.bin")) {
1522 return false;
1523 }
1524 let Some(plan) = CacheWritePlan::from_index(self, git_head) else {
1525 return false;
1526 };
1527
1528 let write_result = {
1529 let mut sources = self.compaction_record_sources(Arc::clone(&plan.id_map));
1530 write_cache_file_from_sources(cache_dir, &plan, &mut sources)
1531 };
1532
1533 match write_result {
1534 Ok(base) => {
1535 self.base = Some(Arc::new(base));
1536 self.delta = Arc::new(DeltaState::default());
1537 self.delta_file_trigrams.clear();
1538 self.delta_packed_bytes = 0;
1539 self.base_file_count = u32::try_from(plan.files.len()).unwrap_or(u32::MAX);
1540 self.files = Arc::new(plan.files);
1541 self.path_to_id = Arc::new(plan.path_to_id);
1542 self.unindexed_files = Arc::new(plan.unindexed_files);
1543 self.file_trigram_count = Arc::new(plan.file_trigram_count);
1544 self.git_head = plan.git_head.filter(|head| !head.is_empty());
1545 self.ignore_rules_fingerprint = plan.ignore_fingerprint;
1546 true
1547 }
1548 Err(error) => {
1549 log::warn!("search index: failed to write disk cache: {}", error);
1550 false
1551 }
1552 }
1553 }
1554
1555 pub fn read_from_disk(cache_dir: &Path, current_canonical_root: &Path) -> Option<Self> {
1556 Self::read_from_disk_with_options(cache_dir, current_canonical_root, true)
1557 }
1558
1559 pub(crate) fn read_from_disk_borrow_tolerant_with_budget(
1560 cache_dir: &Path,
1561 current_canonical_root: &Path,
1562 max_records: usize,
1563 duration: Duration,
1564 ) -> BorrowedIndexLoad {
1565 let load_started = Instant::now();
1566 let budget = BorrowedIndexLoadBudget::new(max_records, duration);
1567 let outcome = match Self::read_from_disk_with_policy(
1568 cache_dir,
1569 current_canonical_root,
1570 false,
1571 IgnoreRulesLoadPolicy::BorrowTolerant,
1572 Some(&budget),
1573 ) {
1574 Some((index, ignore_rules_differ)) => {
1575 BorrowedIndexLoad::Loaded(index, ignore_rules_differ)
1576 }
1577 None => budget
1578 .stop
1579 .get()
1580 .map(BorrowedIndexLoad::Stopped)
1581 .unwrap_or(BorrowedIndexLoad::Invalid),
1582 };
1583 let outcome_label = match &outcome {
1584 BorrowedIndexLoad::Loaded(_, ignore_rules_differ) if *ignore_rules_differ => "partial",
1585 BorrowedIndexLoad::Loaded(_, _) => "ready",
1586 BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::BudgetExceeded) => "budget_stopped",
1587 BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::Cancelled)
1588 | BorrowedIndexLoad::Invalid => "denied",
1589 };
1590 let key = artifact_cache_key(current_canonical_root);
1591 let build_id = crate::logging::in_flight_build_id(
1592 crate::logging::IndexPlane::Search,
1593 current_canonical_root,
1594 )
1595 .unwrap_or_else(crate::logging::mint_index_build_id);
1596 crate::logging::log_index_event(
1597 crate::logging::IndexEvent::new(
1598 crate::logging::IndexEventKind::ArtifactLoaded,
1599 crate::logging::IndexPlane::Search,
1600 build_id,
1601 current_canonical_root,
1602 key,
1603 )
1604 .field("outcome", outcome_label)
1605 .field("borrowed", "true")
1606 .field(
1607 "elapsed_ms",
1608 load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
1609 ),
1610 );
1611 crate::logging::note_tool_call_wait(
1612 crate::run_tool_call::WaitingOn::ArtifactLoad,
1613 None,
1614 load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
1615 );
1616 outcome
1617 }
1618
1619 fn read_from_disk_with_options(
1620 cache_dir: &Path,
1621 current_canonical_root: &Path,
1622 allow_legacy_repair: bool,
1623 ) -> Option<Self> {
1624 Self::read_from_disk_with_policy(
1625 cache_dir,
1626 current_canonical_root,
1627 allow_legacy_repair,
1628 IgnoreRulesLoadPolicy::Strict,
1629 None,
1630 )
1631 .map(|(index, _)| index)
1632 }
1633
1634 fn read_from_disk_with_policy(
1635 cache_dir: &Path,
1636 current_canonical_root: &Path,
1637 allow_legacy_repair: bool,
1638 ignore_rules_load_policy: IgnoreRulesLoadPolicy,
1639 borrowed_load_budget: Option<&BorrowedIndexLoadBudget>,
1640 ) -> Option<(Self, bool)> {
1641 debug_assert!(current_canonical_root.is_absolute());
1642 let cache_path = cache_dir.join("cache.bin");
1643 let cache_file = open_cache_file_read(&cache_path).ok()?;
1644 let file_len = cache_file.metadata().ok()?.len();
1645 if file_len < 16 {
1646 return None;
1647 }
1648
1649 let mut reader = BufReader::new(cache_file.try_clone().ok()?);
1650 if read_u32(&mut reader).ok()? != CACHE_MAGIC {
1651 return None;
1652 }
1653 if read_u32(&mut reader).ok()? != INDEX_VERSION {
1654 return None;
1655 }
1656 let postings_len_total = read_u64(&mut reader).ok()?;
1657 let postings_section_start = reader.stream_position().ok()?;
1658 let postings_section_end = postings_section_start.checked_add(postings_len_total)?;
1659 if postings_len_total < 4 || postings_section_end > file_len {
1660 return None;
1661 }
1662 let postings_body_end = postings_section_end.checked_sub(4)?;
1663
1664 let mut magic = [0u8; 8];
1665 reader.read_exact(&mut magic).ok()?;
1666 if &magic != INDEX_MAGIC {
1667 return None;
1668 }
1669 if read_u32(&mut reader).ok()? != INDEX_VERSION {
1670 return None;
1671 }
1672
1673 let head_len = read_u32(&mut reader).ok()? as usize;
1674 let root_len = read_u32(&mut reader).ok()? as usize;
1675 let ignore_fingerprint_len = read_u32(&mut reader).ok()? as usize;
1676 let max_file_size = read_u64(&mut reader).ok()?;
1677 let file_count = read_u32(&mut reader).ok()? as usize;
1678 if file_count > MAX_ENTRIES {
1679 return None;
1680 }
1681 if borrowed_load_budget.is_some_and(|budget| !budget.preflight(file_count)) {
1682 return None;
1683 }
1684
1685 if !reader_has_remaining(&mut reader, postings_body_end, head_len).ok()? {
1686 return None;
1687 }
1688 let mut head_bytes = vec![0u8; head_len];
1689 reader.read_exact(&mut head_bytes).ok()?;
1690 let git_head = String::from_utf8(head_bytes)
1691 .ok()
1692 .filter(|head| !head.is_empty());
1693
1694 if !reader_has_remaining(&mut reader, postings_body_end, root_len).ok()? {
1695 return None;
1696 }
1697 let mut root_bytes = vec![0u8; root_len];
1698 reader.read_exact(&mut root_bytes).ok()?;
1699 let _stored_project_root = PathBuf::from(String::from_utf8(root_bytes).ok()?);
1700 let project_root = current_canonical_root.to_path_buf();
1701
1702 if !reader_has_remaining(&mut reader, postings_body_end, ignore_fingerprint_len).ok()? {
1703 return None;
1704 }
1705 let mut ignore_fingerprint_bytes = vec![0u8; ignore_fingerprint_len];
1706 reader.read_exact(&mut ignore_fingerprint_bytes).ok()?;
1707 let stored_ignore_rules_fingerprint = String::from_utf8(ignore_fingerprint_bytes).ok()?;
1708 let current_ignore_rules_fingerprint = ignore_rules_fingerprint(&project_root);
1709 let ignore_rules_differ =
1710 stored_ignore_rules_fingerprint != current_ignore_rules_fingerprint;
1711 if ignore_rules_differ && ignore_rules_load_policy == IgnoreRulesLoadPolicy::Strict {
1712 return None;
1713 }
1714
1715 let mut files = Vec::with_capacity(file_count);
1716 let mut path_to_id = HashMap::new();
1717 let mut unindexed_files = HashSet::new();
1718 let mut canonical_parents = ParentCanonicalizationMemo::default();
1719
1720 for file_id in 0..file_count {
1721 if borrowed_load_budget.is_some_and(|budget| !budget.checkpoint_at(file_id)) {
1722 return None;
1723 }
1724 if !reader_has_remaining(&mut reader, postings_body_end, MIN_FILE_ENTRY_BYTES).ok()? {
1725 return None;
1726 }
1727 let mut unindexed = [0u8; 1];
1728 reader.read_exact(&mut unindexed).ok()?;
1729 let path_len = read_u32(&mut reader).ok()? as usize;
1730 let size = read_u64(&mut reader).ok()?;
1731 let secs = read_u64(&mut reader).ok()?;
1732 let nanos = read_u32(&mut reader).ok()?;
1733 let mut hash_bytes = [0u8; 32];
1734 reader.read_exact(&mut hash_bytes).ok()?;
1735 let content_hash = blake3::Hash::from_bytes(hash_bytes);
1736 if nanos >= 1_000_000_000 {
1737 return None;
1738 }
1739 if !reader_has_remaining(&mut reader, postings_body_end, path_len).ok()? {
1740 return None;
1741 }
1742 let mut path_bytes = vec![0u8; path_len];
1743 reader.read_exact(&mut path_bytes).ok()?;
1744 let relative_path = PathBuf::from(String::from_utf8(path_bytes).ok()?);
1745 let full_path = cached_path_under_root_with_memo(
1746 &project_root,
1747 &relative_path,
1748 &mut canonical_parents,
1749 )?;
1750 let file_id_u32 = u32::try_from(file_id).ok()?;
1751
1752 files.push(FileEntry {
1753 path: full_path.clone(),
1754 size,
1755 modified: UNIX_EPOCH + Duration::new(secs, nanos),
1756 content_hash,
1757 });
1758 path_to_id.insert(full_path, file_id_u32);
1759 if unindexed[0] == 1 {
1760 unindexed_files.insert(file_id_u32);
1761 }
1762 }
1763
1764 if !reader_has_remaining(&mut reader, postings_body_end, 8).ok()? {
1765 return None;
1766 }
1767 let postings_blob_len = read_u64(&mut reader).ok()?;
1768 let postings_blob_start = reader.stream_position().ok()?;
1769 let postings_blob_end = postings_blob_start.checked_add(postings_blob_len)?;
1770 if postings_blob_end > postings_body_end || postings_blob_len % POSTING_BYTES as u64 != 0 {
1771 return None;
1772 }
1773
1774 let lookup_section_start = postings_section_end;
1775 if lookup_section_start >= file_len {
1776 return None;
1777 }
1778 let lookup_section_len = file_len.checked_sub(lookup_section_start)?;
1779 if borrowed_load_budget
1780 .is_some_and(|budget| !budget.preflight_lookup_bytes(lookup_section_len, file_count))
1781 {
1782 return None;
1783 }
1784 let mut lookup_file = cache_file.try_clone().ok()?;
1785 lookup_file
1786 .seek(SeekFrom::Start(lookup_section_start))
1787 .ok()?;
1788 let mut lookup_bytes = Vec::new();
1789 lookup_file.read_to_end(&mut lookup_bytes).ok()?;
1790 if lookup_bytes.len() < 4 {
1791 return None;
1792 }
1793 verify_crc32_bytes_slice(&lookup_bytes).ok()?;
1794 let lookup_body_len = lookup_bytes.len().checked_sub(4)?;
1795 let mut lookup_reader = BufReader::new(Cursor::new(&lookup_bytes));
1796 let mut lookup_magic = [0u8; 8];
1797 lookup_reader.read_exact(&mut lookup_magic).ok()?;
1798 if &lookup_magic != LOOKUP_MAGIC {
1799 return None;
1800 }
1801 if read_u32(&mut lookup_reader).ok()? != INDEX_VERSION {
1802 return None;
1803 }
1804 let entry_count = read_u32(&mut lookup_reader).ok()? as usize;
1805 if entry_count > MAX_ENTRIES {
1806 return None;
1807 }
1808 let total_records = file_count.checked_add(entry_count)?;
1809 if borrowed_load_budget.is_some_and(|budget| !budget.preflight(total_records)) {
1810 return None;
1811 }
1812 let remaining_lookup = remaining_bytes(&mut lookup_reader, lookup_body_len)?;
1813 let minimum_lookup_bytes = entry_count.checked_mul(LOOKUP_ENTRY_BYTES)?;
1814 if minimum_lookup_bytes > remaining_lookup {
1815 return None;
1816 }
1817
1818 let mut lookup = Vec::with_capacity(entry_count);
1819 let mut previous_trigram = None;
1820 for lookup_index in 0..entry_count {
1821 if borrowed_load_budget.is_some_and(|budget| {
1822 !budget.checkpoint_at(file_count.saturating_add(lookup_index))
1823 }) {
1824 return None;
1825 }
1826 let trigram = read_u32(&mut lookup_reader).ok()?;
1827 let offset = read_u64(&mut lookup_reader).ok()?;
1828 let count = read_u32(&mut lookup_reader).ok()?;
1829 if count as usize > MAX_ENTRIES {
1830 return None;
1831 }
1832 if previous_trigram.is_some_and(|previous| previous >= trigram) {
1833 return None;
1834 }
1835 previous_trigram = Some(trigram);
1836 let bytes_len = (count as u64).checked_mul(POSTING_BYTES as u64)?;
1837 let end = offset.checked_add(bytes_len)?;
1838 if end > postings_blob_len {
1839 return None;
1840 }
1841 lookup.push(LookupEntry {
1842 trigram,
1843 offset,
1844 count,
1845 });
1846 }
1847
1848 let base = BasePostings {
1849 file: Arc::new(cache_file),
1850 postings_blob_start,
1851 postings_blob_len,
1852 lookup: Arc::new(lookup),
1853 };
1854
1855 if borrowed_load_budget.is_some_and(|budget| !budget.checkpoint()) {
1856 return None;
1857 }
1858 let (file_trigram_count, migrated_counts) = match read_file_trigram_count_extension(
1859 &base,
1860 postings_blob_end,
1861 postings_body_end,
1862 file_count,
1863 ) {
1864 Ok(Some(counts)) => (counts, false),
1865 Ok(None) => (
1866 compute_file_trigram_counts_from_base(&base, file_count, borrowed_load_budget)
1867 .ok()?,
1868 true,
1869 ),
1870 Err(_) => return None,
1871 };
1872
1873 let mut index = SearchIndex {
1874 base: Some(Arc::new(base)),
1875 delta: Arc::new(DeltaState::default()),
1876 delta_file_trigrams: HashMap::new(),
1877 files: Arc::new(files),
1878 path_to_id: Arc::new(path_to_id),
1879 ready: false,
1880 build_denied: false,
1881 project_root,
1882 git_head,
1883 max_file_size,
1884 ignore_rules_fingerprint: current_ignore_rules_fingerprint,
1885 file_trigram_count: Arc::new(file_trigram_count),
1886 unindexed_files: Arc::new(unindexed_files),
1887 base_file_count: u32::try_from(file_count).ok()?,
1888 delta_packed_bytes: 0,
1889 compaction_state: Arc::new(Mutex::new(CompactionState::default())),
1890 };
1891
1892 if migrated_counts && allow_legacy_repair {
1893 if let Ok(_lock) = CacheLock::acquire(cache_dir, current_canonical_root) {
1894 let head = index.git_head.clone();
1895 index.write_to_disk(cache_dir, head.as_deref());
1896 }
1897 }
1898
1899 Some((index, ignore_rules_differ))
1900 }
1901
1902 pub fn stored_git_head(&self) -> Option<&str> {
1903 self.git_head.as_deref()
1904 }
1905
1906 pub(crate) fn borrowed_stat_mismatch_count(&self) -> usize {
1910 self.files
1911 .iter()
1912 .filter(|entry| {
1913 let Ok(metadata) = fs::metadata(&entry.path) else {
1914 return true;
1915 };
1916 metadata.len() != entry.size
1917 || metadata.modified().unwrap_or(UNIX_EPOCH) != entry.modified
1918 })
1919 .count()
1920 }
1921
1922 pub(crate) fn configured_max_file_size(&self) -> u64 {
1923 self.max_file_size
1924 }
1925
1926 pub(crate) fn set_ready(&mut self, ready: bool) {
1927 self.ready = ready;
1928 }
1929
1930 pub(crate) fn verify_against_disk_with_strategy(
1931 &mut self,
1932 current_head: Option<String>,
1933 verify_strategy: cache_freshness::VerifyStrategy,
1934 ) -> bool {
1935 self.git_head = current_head;
1936 let changed = verify_file_mtimes(self, verify_strategy);
1937 self.ready = true;
1938 changed
1939 }
1940
1941 #[cfg(debug_assertions)]
1942 #[doc(hidden)]
1943 pub fn verify_against_disk_for_debug(&mut self, current_head: Option<String>) {
1944 let _ = self.verify_against_disk_with_strategy(
1945 current_head,
1946 cache_freshness::VerifyStrategy::Strict,
1947 );
1948 }
1949
1950 #[cfg(test)]
1951 pub(crate) fn rebuild_or_refresh(
1952 root: &Path,
1953 max_file_size: u64,
1954 current_head: Option<String>,
1955 baseline: Option<SearchIndex>,
1956 cache_dir: Option<&Path>,
1957 ) -> Self {
1958 Self::rebuild_or_refresh_with_strategy(
1959 root,
1960 max_file_size,
1961 current_head,
1962 baseline,
1963 cache_dir,
1964 cache_freshness::VerifyStrategy::Strict,
1965 )
1966 }
1967
1968 pub(crate) fn rebuild_or_refresh_with_strategy(
1969 root: &Path,
1970 max_file_size: u64,
1971 current_head: Option<String>,
1972 baseline: Option<SearchIndex>,
1973 cache_dir: Option<&Path>,
1974 verify_strategy: cache_freshness::VerifyStrategy,
1975 ) -> Self {
1976 if let Some(mut baseline) = baseline {
1977 if baseline.max_file_size != max_file_size {
1978 return match cache_dir {
1979 Some(cache_dir) => {
1980 SearchIndex::build_with_limit_to_cache_dir(root, max_file_size, cache_dir)
1981 }
1982 None => SearchIndex::build_with_limit(root, max_file_size),
1983 };
1984 }
1985 baseline.project_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1986 let current_ignore_rules_fingerprint = ignore_rules_fingerprint(&baseline.project_root);
1987 if baseline.ignore_rules_fingerprint != current_ignore_rules_fingerprint {
1988 return match cache_dir {
1989 Some(cache_dir) => {
1990 SearchIndex::build_with_limit_to_cache_dir(root, max_file_size, cache_dir)
1991 }
1992 None => SearchIndex::build_with_limit(root, max_file_size),
1993 };
1994 }
1995 baseline.ignore_rules_fingerprint = current_ignore_rules_fingerprint;
1996
1997 if baseline.git_head == current_head || current_head.is_none() {
1998 baseline.git_head = current_head;
2005 let _ = verify_file_mtimes(&mut baseline, verify_strategy);
2006 baseline.ready = true;
2007 return baseline;
2008 }
2009
2010 if let (Some(previous), Some(current)) =
2011 (baseline.git_head.clone(), current_head.clone())
2012 {
2013 let project_root = baseline.project_root.clone();
2014 if apply_git_diff_updates(&mut baseline, &project_root, &previous, ¤t) {
2015 baseline.git_head = Some(current);
2016 let _ = verify_file_mtimes(&mut baseline, verify_strategy);
2017 baseline.ready = true;
2018 return baseline;
2019 }
2020 }
2021 }
2022
2023 match cache_dir {
2024 Some(cache_dir) => {
2025 SearchIndex::build_with_limit_to_cache_dir(root, max_file_size, cache_dir)
2026 }
2027 None => SearchIndex::build_with_limit(root, max_file_size),
2028 }
2029 }
2030
2031 fn allocate_file_id_with_metadata(
2032 &mut self,
2033 path: &Path,
2034 metadata: SearchFileMetadata,
2035 ) -> Option<u32> {
2036 let file_id = u32::try_from(self.files.len()).ok()?;
2037 Arc::make_mut(&mut self.files).push(FileEntry {
2038 path: path.to_path_buf(),
2039 size: metadata.size,
2040 modified: metadata.modified,
2041 content_hash: cache_freshness::zero_hash(),
2042 });
2043 Arc::make_mut(&mut self.path_to_id).insert(path.to_path_buf(), file_id);
2044 ensure_count_slot(Arc::make_mut(&mut self.file_trigram_count), file_id);
2045 Some(file_id)
2046 }
2047
2048 fn track_unindexed_file_with_metadata(
2049 &mut self,
2050 path: &Path,
2051 metadata: SearchFileMetadata,
2052 ) -> bool {
2053 let Some(file_id) = self.allocate_file_id_with_metadata(path, metadata) else {
2054 return false;
2055 };
2056 Arc::make_mut(&mut self.unindexed_files).insert(file_id);
2057 if let Some(count) = Arc::make_mut(&mut self.file_trigram_count).get_mut(file_id as usize) {
2058 *count = 0;
2059 }
2060 true
2061 }
2062
2063 fn active_file_ids(&self) -> Vec<u32> {
2064 self.snapshot().active_file_ids()
2065 }
2066
2067 #[cfg(test)]
2068 fn postings_for_trigram(&self, trigram: u32, filter: Option<PostingFilter>) -> Vec<u32> {
2069 self.snapshot().postings_for_trigram(trigram, filter)
2070 }
2071
2072 fn update_compaction_flags(&mut self, changed_path: Option<&Path>) {
2073 let delta_files = self.delta_file_trigrams.len();
2074 let hard = delta_files >= DELTA_COMPACT_HARD_FILES
2075 || self.delta_packed_bytes >= DELTA_COMPACT_HARD_BYTES;
2076 let soft = delta_files >= DELTA_COMPACT_SOFT_FILES
2077 || self.delta_packed_bytes >= DELTA_COMPACT_SOFT_BYTES;
2078 if let Ok(mut state) = self.compaction_state.lock() {
2079 if state.running {
2080 if let Some(path) = changed_path {
2081 state.buffered_paths.push(path.to_path_buf());
2082 }
2083 if soft || hard {
2084 state.requested_again = true;
2085 }
2086 } else if hard || (soft && !state.requested_again) {
2087 state.requested_again = true;
2088 }
2089 }
2090 }
2091
2092 fn compaction_record_sources(
2093 &self,
2094 id_map: Arc<HashMap<u32, u32>>,
2095 ) -> Vec<Box<dyn PostingRecordSource>> {
2096 let mut sources: Vec<Box<dyn PostingRecordSource>> = Vec::new();
2097 if let Some(base) = self.base.clone() {
2098 sources.push(Box::new(BaseRecordSource::new(
2099 base,
2100 Arc::clone(&id_map),
2101 Arc::clone(&self.delta),
2102 )));
2103 }
2104
2105 let mut delta_records = Vec::new();
2106 for (&trigram, postings) in &self.delta.postings {
2107 for posting in postings {
2108 let Some(mapped_file_id) = id_map.get(&posting.file_id).copied() else {
2109 continue;
2110 };
2111 delta_records.push(SpillRecord {
2112 trigram,
2113 file_id: mapped_file_id,
2114 next_mask: posting.next_mask,
2115 loc_mask: posting.loc_mask,
2116 });
2117 }
2118 }
2119 if !delta_records.is_empty() {
2120 delta_records.sort_unstable_by_key(|record| (record.trigram, record.file_id));
2121 sources.push(Box::new(VecRecordSource::new(delta_records)));
2122 }
2123 sources
2124 }
2125}
2126
2127impl BasePostings {
2128 fn lookup_entry(&self, trigram: u32) -> Option<LookupEntry> {
2129 self.lookup
2130 .binary_search_by_key(&trigram, |entry| entry.trigram)
2131 .ok()
2132 .and_then(|index| self.lookup.get(index).copied())
2133 }
2134
2135 fn read_posting_bytes(&self, entry: LookupEntry) -> std::io::Result<Vec<u8>> {
2136 let bytes_len = (entry.count as usize)
2137 .checked_mul(POSTING_BYTES)
2138 .ok_or_else(|| std::io::Error::other("posting list too large"))?;
2139 let offset = self
2140 .postings_blob_start
2141 .checked_add(entry.offset)
2142 .ok_or_else(|| std::io::Error::other("posting offset overflow"))?;
2143 let end = entry
2144 .offset
2145 .checked_add(bytes_len as u64)
2146 .ok_or_else(|| std::io::Error::other("posting offset overflow"))?;
2147 if end > self.postings_blob_len {
2148 return Err(std::io::Error::other("posting list exceeds blob"));
2149 }
2150 let mut bytes = vec![0u8; bytes_len];
2151 pread_exact(&self.file, offset, &mut bytes)?;
2152 Ok(bytes)
2153 }
2154
2155 fn for_each_posting(
2156 &self,
2157 entry: LookupEntry,
2158 mut visit: impl FnMut(u32, u8, u8) -> bool,
2159 ) -> std::io::Result<()> {
2160 let bytes = self.read_posting_bytes(entry)?;
2161 for chunk in bytes.chunks_exact(POSTING_BYTES) {
2162 if !visit(
2163 u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]),
2164 chunk[4],
2165 chunk[5],
2166 ) {
2167 break;
2168 }
2169 }
2170 Ok(())
2171 }
2172
2173 fn read_postings(&self, entry: LookupEntry) -> std::io::Result<Vec<Posting>> {
2174 let bytes = self.read_posting_bytes(entry)?;
2175 let mut postings = Vec::with_capacity(entry.count as usize);
2176 for chunk in bytes.chunks_exact(POSTING_BYTES) {
2177 postings.push(Posting {
2178 file_id: u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]),
2179 next_mask: chunk[4],
2180 loc_mask: chunk[5],
2181 });
2182 }
2183 Ok(postings)
2184 }
2185}
2186
2187impl SearchIndexSnapshot {
2188 pub fn grep(
2189 &self,
2190 pattern: &str,
2191 case_sensitive: bool,
2192 include: &[String],
2193 exclude: &[String],
2194 search_root: &Path,
2195 max_results: usize,
2196 ) -> GrepResult {
2197 match pattern_compile::compile(
2198 pattern,
2199 CompileOpts {
2200 case_insensitive: !case_sensitive,
2201 ..CompileOpts::default()
2202 },
2203 ) {
2204 CompileResult::Ok(compiled) => {
2205 self.search_grep(&compiled, include, exclude, search_root, max_results)
2206 }
2207 CompileResult::InvalidPattern { .. } | CompileResult::UnsupportedSyntax { .. } => {
2208 self.empty_grep_result()
2209 }
2210 }
2211 }
2212
2213 pub fn search_grep(
2214 &self,
2215 pattern: &CompiledPattern,
2216 include: &[String],
2217 exclude: &[String],
2218 search_root: &Path,
2219 max_results: usize,
2220 ) -> GrepResult {
2221 self.search_grep_profiled(pattern, include, exclude, search_root, max_results, None)
2222 .0
2223 }
2224
2225 pub(crate) fn search_grep_bounded(
2226 &self,
2227 pattern: &CompiledPattern,
2228 include: &[String],
2229 exclude: &[String],
2230 search_root: &Path,
2231 max_results: usize,
2232 path_exclusion: Option<GrepPathExclusion>,
2233 max_files: usize,
2234 budget: Duration,
2235 ) -> GrepResult {
2236 let filters = build_path_filters(include, exclude).unwrap_or_default();
2237 let query = decompose_grep_pattern(pattern);
2238 self.search_grep_profiled_with_filters_and_query_and_limits(
2239 pattern,
2240 &query,
2241 &filters,
2242 search_root,
2243 max_results,
2244 path_exclusion,
2245 Some((max_files, budget)),
2246 )
2247 .0
2248 }
2249
2250 pub(crate) fn search_grep_profiled(
2251 &self,
2252 pattern: &CompiledPattern,
2253 include: &[String],
2254 exclude: &[String],
2255 search_root: &Path,
2256 max_results: usize,
2257 path_exclusion: Option<GrepPathExclusion>,
2258 ) -> (GrepResult, GrepQueryPhaseTimings) {
2259 let filters = build_path_filters(include, exclude).unwrap_or_default();
2260 self.search_grep_profiled_with_filters(
2261 pattern,
2262 &filters,
2263 search_root,
2264 max_results,
2265 path_exclusion,
2266 )
2267 }
2268
2269 pub(crate) fn search_grep_profiled_with_filters(
2270 &self,
2271 pattern: &CompiledPattern,
2272 filters: &PathFilters,
2273 search_root: &Path,
2274 max_results: usize,
2275 path_exclusion: Option<GrepPathExclusion>,
2276 ) -> (GrepResult, GrepQueryPhaseTimings) {
2277 let query_started = Instant::now();
2278 let query = decompose_grep_pattern(pattern);
2279 let query_decomposition = query_started.elapsed();
2280 let (result, mut timings) = self.search_grep_profiled_with_filters_and_query(
2281 pattern,
2282 &query,
2283 filters,
2284 search_root,
2285 max_results,
2286 path_exclusion,
2287 );
2288 timings.trigram_lookup += query_decomposition;
2289 (result, timings)
2290 }
2291
2292 pub(crate) fn search_grep_profiled_with_filters_and_query(
2294 &self,
2295 pattern: &CompiledPattern,
2296 query: &RegexQuery,
2297 filters: &PathFilters,
2298 search_root: &Path,
2299 max_results: usize,
2300 path_exclusion: Option<GrepPathExclusion>,
2301 ) -> (GrepResult, GrepQueryPhaseTimings) {
2302 self.search_grep_profiled_with_filters_and_query_and_limits(
2303 pattern,
2304 query,
2305 filters,
2306 search_root,
2307 max_results,
2308 path_exclusion,
2309 None,
2310 )
2311 }
2312
2313 fn search_grep_profiled_with_filters_and_query_and_limits(
2314 &self,
2315 pattern: &CompiledPattern,
2316 query: &RegexQuery,
2317 filters: &PathFilters,
2318 search_root: &Path,
2319 max_results: usize,
2320 path_exclusion: Option<GrepPathExclusion>,
2321 verification_limits: Option<(usize, Duration)>,
2322 ) -> (GrepResult, GrepQueryPhaseTimings) {
2323 let matcher = match pattern {
2324 CompiledPattern::Literal(literal) => SearchMatcher::Literal(literal.clone()),
2325 CompiledPattern::Regex { compiled, .. } => SearchMatcher::Regex(compiled.clone()),
2326 };
2327
2328 let search_root = canonicalize_for_search_membership(search_root);
2329
2330 let job_cancellation = crate::executor::current_job_cancellation();
2331 let trigram_started = Instant::now();
2332 let fully_degraded = query.and_trigrams.is_empty() && query.or_groups.is_empty();
2333 let candidate_ids = self.candidates(query);
2334 let trigram_lookup = trigram_started.elapsed();
2335
2336 let candidate_filter_started = Instant::now();
2337 let candidate_files: Vec<&FileEntry> = candidate_ids
2338 .into_iter()
2339 .filter_map(|file_id| self.files.get(file_id as usize))
2340 .filter(|file| !file.path.as_os_str().is_empty())
2341 .filter(|file| is_within_search_root(&search_root, &file.path))
2342 .filter(|file| {
2343 path_exclusion.is_none_or(|exclude| !exclude(&file.path, &self.project_root))
2344 })
2345 .filter(|file| filters.matches(&self.project_root, &file.path))
2346 .collect();
2347 let candidate_count = candidate_files.len();
2348 let candidate_filter = candidate_filter_started.elapsed();
2349
2350 let total_matches = AtomicUsize::new(0);
2351 let files_searched = AtomicUsize::new(0);
2352 let files_with_matches = AtomicUsize::new(0);
2353 let bytes_verified = AtomicUsize::new(0);
2354 let truncated = AtomicBool::new(false);
2355 let engine_capped = AtomicBool::new(false);
2356 let stop_after = max_results.saturating_mul(2);
2357 let stop_scan = Arc::new(AtomicBool::new(false));
2358 let verification_started = Instant::now();
2359 let verification_claims = AtomicUsize::new(0);
2360 let claim_verification = || {
2361 let Some((max_files, budget)) = verification_limits else {
2362 return true;
2363 };
2364 if verification_started.elapsed() >= budget {
2365 return false;
2366 }
2367 verification_claims.fetch_add(1, Ordering::Relaxed) < max_files
2368 };
2369
2370 let pread_started = Instant::now();
2371 let mut matches = if candidate_files.len() > 10 {
2372 candidate_files
2373 .par_iter()
2374 .map(|file| {
2375 if grep_scan_should_stop(
2376 Some(&stop_scan),
2377 &truncated,
2378 &total_matches,
2379 stop_after,
2380 job_cancellation.as_ref(),
2381 ) {
2382 engine_capped.store(true, Ordering::Relaxed);
2383 return Vec::new();
2384 }
2385 if !claim_verification() {
2386 truncated.store(true, Ordering::Relaxed);
2387 engine_capped.store(true, Ordering::Relaxed);
2388 stop_scan.store(true, Ordering::Relaxed);
2389 return Vec::new();
2390 }
2391 search_candidate_file(
2392 file,
2393 &matcher,
2394 max_results,
2395 stop_after,
2396 &total_matches,
2397 &files_searched,
2398 &files_with_matches,
2399 &bytes_verified,
2400 &truncated,
2401 &engine_capped,
2402 Some(&stop_scan),
2403 job_cancellation.as_ref(),
2404 )
2405 })
2406 .reduce(Vec::new, |mut left, mut right| {
2407 left.append(&mut right);
2411 left
2412 })
2413 } else {
2414 let mut matches = Vec::new();
2415 for file in candidate_files {
2416 if !claim_verification() {
2417 truncated.store(true, Ordering::Relaxed);
2418 engine_capped.store(true, Ordering::Relaxed);
2419 break;
2420 }
2421 matches.extend(search_candidate_file(
2422 file,
2423 &matcher,
2424 max_results,
2425 stop_after,
2426 &total_matches,
2427 &files_searched,
2428 &files_with_matches,
2429 &bytes_verified,
2430 &truncated,
2431 &engine_capped,
2432 None,
2433 job_cancellation.as_ref(),
2434 ));
2435
2436 if should_stop_search(&truncated, &total_matches, stop_after) {
2437 engine_capped.store(true, Ordering::Relaxed);
2438 break;
2439 }
2440 }
2441 matches
2442 };
2443 let pread_verify = pread_started.elapsed();
2444
2445 let post_filter_started = Instant::now();
2446 sort_shared_grep_matches_by_cached_mtime_desc(&mut matches, &self.project_root, |path| {
2447 self.path_to_id
2448 .get(path)
2449 .and_then(|file_id| self.files.get(*file_id as usize))
2450 .map(|file| file.modified)
2451 });
2452
2453 let matches = matches
2454 .into_iter()
2455 .map(|matched| GrepMatch {
2456 file: matched.file.as_ref().clone(),
2457 line: matched.line,
2458 column: matched.column,
2459 line_text: matched.line_text,
2460 match_text: matched.match_text,
2461 })
2462 .collect();
2463
2464 let result = GrepResult {
2465 total_matches: total_matches.load(Ordering::Relaxed),
2466 matches,
2467 files_searched: files_searched.load(Ordering::Relaxed),
2468 files_with_matches: files_with_matches.load(Ordering::Relaxed),
2469 index_status: if self.ready {
2470 IndexStatus::Ready
2471 } else {
2472 IndexStatus::Building
2473 },
2474 truncated: truncated.load(Ordering::Relaxed),
2475 fully_degraded,
2476 engine_capped: engine_capped.load(Ordering::Relaxed),
2477 walk_truncated: false,
2478 skipped_foreign_mounts: 0,
2479 };
2480 let post_filter = candidate_filter + post_filter_started.elapsed();
2481 let phases = GrepQueryPhaseTimings {
2482 trigram_lookup,
2483 pread_verify,
2484 post_filter,
2485 candidate_count,
2486 bytes_verified: bytes_verified.load(Ordering::Relaxed),
2487 };
2488 (result, phases)
2489 }
2490
2491 fn empty_grep_result(&self) -> GrepResult {
2492 GrepResult {
2493 matches: Vec::new(),
2494 total_matches: 0,
2495 files_searched: 0,
2496 files_with_matches: 0,
2497 index_status: if self.ready {
2498 IndexStatus::Ready
2499 } else {
2500 IndexStatus::Building
2501 },
2502 truncated: false,
2503 fully_degraded: false,
2504 engine_capped: false,
2505 walk_truncated: false,
2506 skipped_foreign_mounts: 0,
2507 }
2508 }
2509
2510 pub fn glob(&self, pattern: &str, search_root: &Path) -> Vec<PathBuf> {
2511 self.glob_profiled(pattern, search_root, true).0
2512 }
2513
2514 pub(crate) fn glob_profiled(
2515 &self,
2516 pattern: &str,
2517 search_root: &Path,
2518 sort_by_mtime: bool,
2519 ) -> (Vec<PathBuf>, bool, usize) {
2520 let filters = match build_path_filters(&[pattern.to_string()], &[]) {
2521 Ok(filters) => filters,
2522 Err(_) => return (Vec::new(), false, 0),
2523 };
2524 let raw_root = search_root.to_path_buf();
2531 let canonical_root = canonicalize_for_search_membership(search_root);
2532 let containing_root = |path: &Path| -> Option<&Path> {
2533 if is_within_search_root(&raw_root, path) {
2534 Some(raw_root.as_path())
2535 } else if is_within_search_root(&canonical_root, path) {
2536 Some(canonical_root.as_path())
2537 } else {
2538 None
2539 }
2540 };
2541 let entries_visited = self.files.len();
2542 let mut scope_has_files = false;
2543 let mut entries = self
2544 .files
2545 .iter()
2546 .filter(|file| !file.path.as_os_str().is_empty())
2547 .filter_map(|file| {
2548 let root = containing_root(&file.path)?;
2549 scope_has_files = true;
2550 Some((file, root))
2551 })
2552 .filter(|(file, root)| filters.matches(root, &file.path))
2557 .map(|(file, _)| (file.path.clone(), file.modified))
2558 .collect::<Vec<_>>();
2559
2560 if sort_by_mtime {
2561 entries.sort_by(|(left_path, left_mtime), (right_path, right_mtime)| {
2562 right_mtime
2563 .cmp(left_mtime)
2564 .then_with(|| left_path.cmp(right_path))
2565 });
2566 }
2567
2568 (
2569 entries.into_iter().map(|(path, _)| path).collect(),
2570 scope_has_files,
2571 entries_visited,
2572 )
2573 }
2574
2575 pub fn candidates(&self, query: &RegexQuery) -> Vec<u32> {
2576 if query.and_trigrams.is_empty() && query.or_groups.is_empty() {
2577 return self.active_file_ids();
2578 }
2579
2580 let mut and_trigrams = query.and_trigrams.clone();
2581 and_trigrams.sort_unstable_by_key(|trigram| self.posting_count(*trigram));
2582
2583 let mut current: Option<Vec<u32>> = None;
2584
2585 for trigram in and_trigrams {
2586 let filter = query.and_filters.get(&trigram).copied();
2587 let matches = self.postings_for_trigram(trigram, filter);
2588 current = Some(match current.take() {
2589 Some(existing) => intersect_sorted_ids(&existing, &matches),
2590 None => matches,
2591 });
2592
2593 if current.as_ref().is_some_and(|ids| ids.is_empty()) {
2594 break;
2595 }
2596 }
2597
2598 let mut current = current.unwrap_or_else(|| self.active_file_ids());
2599
2600 for (index, group) in query.or_groups.iter().enumerate() {
2601 let mut group_matches = Vec::new();
2602 let filters = query.or_filters.get(index);
2603
2604 for trigram in group {
2605 let filter = filters.and_then(|filters| filters.get(trigram).copied());
2606 let matches = self.postings_for_trigram(*trigram, filter);
2607 if group_matches.is_empty() {
2608 group_matches = matches;
2609 } else {
2610 group_matches = union_sorted_ids(&group_matches, &matches);
2611 }
2612 }
2613
2614 current = intersect_sorted_ids(¤t, &group_matches);
2615 if current.is_empty() {
2616 break;
2617 }
2618 }
2619
2620 let mut unindexed = self
2621 .unindexed_files
2622 .iter()
2623 .copied()
2624 .filter(|file_id| self.is_active_file(*file_id))
2625 .collect::<Vec<_>>();
2626 if !unindexed.is_empty() {
2627 unindexed.sort_unstable();
2628 current = union_sorted_ids(¤t, &unindexed);
2629 }
2630
2631 current
2632 }
2633
2634 fn posting_count(&self, trigram: u32) -> usize {
2635 let base_count = self
2636 .base
2637 .as_ref()
2638 .and_then(|base| base.lookup_entry(trigram))
2639 .map_or(0usize, |entry| entry.count as usize);
2640 base_count.saturating_add(self.delta.postings.get(&trigram).map_or(0usize, Vec::len))
2641 }
2642
2643 fn active_file_ids(&self) -> Vec<u32> {
2644 let mut ids: Vec<u32> = self.path_to_id.values().copied().collect();
2645 ids.retain(|file_id| self.is_active_file(*file_id));
2646 ids.sort_unstable();
2647 ids
2648 }
2649
2650 fn is_active_file(&self, file_id: u32) -> bool {
2651 if self.delta.superseded.contains(&file_id) {
2652 return false;
2653 }
2654 self.files
2655 .get(file_id as usize)
2656 .map(|file| !file.path.as_os_str().is_empty())
2657 .unwrap_or(false)
2658 }
2659
2660 fn postings_for_trigram(&self, trigram: u32, filter: Option<PostingFilter>) -> Vec<u32> {
2661 #[cfg(debug_assertions)]
2662 POSTINGS_FOR_TRIGRAM_CALLS.with(|calls| calls.set(calls.get().saturating_add(1)));
2663
2664 let mut matches = Vec::new();
2665
2666 if let Some(base_entry) = self
2667 .base
2668 .as_ref()
2669 .and_then(|base| base.lookup_entry(trigram))
2670 {
2671 if let Some(base) = &self.base {
2672 matches.reserve(base_entry.count as usize);
2673 let _ = base.for_each_posting(base_entry, |file_id, next_mask, loc_mask| {
2674 if crate::executor::current_job_cancelled() {
2675 return false;
2676 }
2677 if self.delta.superseded.contains(&file_id) {
2678 return true;
2679 }
2680 let posting = Posting {
2681 file_id,
2682 next_mask,
2683 loc_mask,
2684 };
2685 if !posting_matches_filter(&posting, filter) {
2686 return true;
2687 }
2688 if self.is_active_file(file_id) {
2689 matches.push(file_id);
2690 }
2691 true
2692 });
2693 }
2694 }
2695
2696 if let Some(postings) = self.delta.postings.get(&trigram) {
2697 matches.reserve(postings.len());
2698 for (posting_index, posting) in postings.iter().enumerate() {
2699 if posting_index % BORROWED_INDEX_CHECKPOINT_INTERVAL == 0
2700 && crate::executor::current_job_cancelled()
2701 {
2702 break;
2703 }
2704 if !posting_matches_filter(posting, filter) {
2705 continue;
2706 }
2707 if self.is_active_file(posting.file_id) {
2708 matches.push(posting.file_id);
2709 }
2710 }
2711 }
2712
2713 if matches.len() > 1 {
2714 matches.sort_unstable();
2715 matches.dedup();
2716 }
2717 matches
2718 }
2719}
2720
2721fn posting_matches_filter(posting: &Posting, filter: Option<PostingFilter>) -> bool {
2722 if let Some(filter) = filter {
2723 if filter.next_mask != 0 && posting.next_mask & filter.next_mask == 0 {
2726 return false;
2727 }
2728 }
2732 true
2733}
2734
2735fn search_candidate_file(
2736 file: &FileEntry,
2737 matcher: &SearchMatcher,
2738 max_results: usize,
2739 stop_after: usize,
2740 total_matches: &AtomicUsize,
2741 files_searched: &AtomicUsize,
2742 files_with_matches: &AtomicUsize,
2743 bytes_verified: &AtomicUsize,
2744 truncated: &AtomicBool,
2745 engine_capped: &AtomicBool,
2746 stop_scan: Option<&Arc<AtomicBool>>,
2747 job_cancellation: Option<&crate::executor::JobCancellation>,
2748) -> Vec<SharedGrepMatch> {
2749 if grep_scan_should_stop(
2750 stop_scan,
2751 truncated,
2752 total_matches,
2753 stop_after,
2754 job_cancellation,
2755 ) {
2756 engine_capped.store(true, Ordering::Relaxed);
2757 return Vec::new();
2758 }
2759
2760 let content = match read_indexed_file_bytes(&file.path) {
2761 Some(content) => content,
2762 None => return Vec::new(),
2763 };
2764 bytes_verified.fetch_add(content.len(), Ordering::Relaxed);
2765 if is_binary_bytes(&content) {
2772 return Vec::new();
2773 }
2774 files_searched.fetch_add(1, Ordering::Relaxed);
2775
2776 let shared_path = Arc::new(file.path.clone());
2777 let mut matches = Vec::new();
2778 let mut line_starts = None;
2779 let mut seen_lines = HashSet::new();
2780 let mut matched_this_file = false;
2781
2782 match matcher {
2783 SearchMatcher::Literal(literal) if !literal.case_insensitive_ascii => {
2784 let needle = &literal.needle;
2785 let finder = memchr::memmem::Finder::new(needle);
2786 let mut start = 0;
2787
2788 while let Some(position) = finder.find(&content[start..]) {
2789 if grep_scan_should_stop(
2790 stop_scan,
2791 truncated,
2792 total_matches,
2793 stop_after,
2794 job_cancellation,
2795 ) {
2796 engine_capped.store(true, Ordering::Relaxed);
2797 break;
2798 }
2799
2800 let offset = start + position;
2801 start = offset + 1;
2802
2803 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2804 let (line, column, line_text) = line_details_bytes(&content, line_starts, offset);
2805 if !seen_lines.insert(line) {
2806 continue;
2807 }
2808
2809 matched_this_file = true;
2810 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2811 if match_number > max_results {
2812 truncated.store(true, Ordering::Relaxed);
2813 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2814 break;
2815 }
2816
2817 let end = offset + needle.len();
2818 matches.push(SharedGrepMatch {
2819 file: shared_path.clone(),
2820 line,
2821 column,
2822 line_text,
2823 match_text: String::from_utf8_lossy(&content[offset..end]).into_owned(),
2824 });
2825 }
2826 }
2827 SearchMatcher::Literal(literal) => {
2828 let needle = &literal.needle;
2829 let search_content = content.to_ascii_lowercase();
2830 let finder = memchr::memmem::Finder::new(needle);
2831 let mut start = 0;
2832
2833 while let Some(position) = finder.find(&search_content[start..]) {
2834 if grep_scan_should_stop(
2835 stop_scan,
2836 truncated,
2837 total_matches,
2838 stop_after,
2839 job_cancellation,
2840 ) {
2841 engine_capped.store(true, Ordering::Relaxed);
2842 break;
2843 }
2844
2845 let offset = start + position;
2846 start = offset + 1;
2847
2848 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2849 let (line, column, line_text) = line_details_bytes(&content, line_starts, offset);
2850 if !seen_lines.insert(line) {
2851 continue;
2852 }
2853
2854 matched_this_file = true;
2855 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2856 if match_number > max_results {
2857 truncated.store(true, Ordering::Relaxed);
2858 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2859 break;
2860 }
2861
2862 let end = offset + needle.len();
2863 matches.push(SharedGrepMatch {
2864 file: shared_path.clone(),
2865 line,
2866 column,
2867 line_text,
2868 match_text: String::from_utf8_lossy(&content[offset..end]).into_owned(),
2869 });
2870 }
2871 }
2872 SearchMatcher::Regex(regex) => {
2873 for matched in regex.find_iter(&content) {
2874 if grep_scan_should_stop(
2875 stop_scan,
2876 truncated,
2877 total_matches,
2878 stop_after,
2879 job_cancellation,
2880 ) {
2881 engine_capped.store(true, Ordering::Relaxed);
2882 break;
2883 }
2884
2885 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2886 let (line, column, line_text) =
2887 line_details_bytes(&content, line_starts, matched.start());
2888 if !seen_lines.insert(line) {
2889 continue;
2890 }
2891
2892 matched_this_file = true;
2893 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2894 if match_number > max_results {
2895 truncated.store(true, Ordering::Relaxed);
2896 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2897 break;
2898 }
2899
2900 matches.push(SharedGrepMatch {
2901 file: shared_path.clone(),
2902 line,
2903 column,
2904 line_text,
2905 match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
2906 });
2907 }
2908 }
2909 }
2910
2911 if matched_this_file {
2912 files_with_matches.fetch_add(1, Ordering::Relaxed);
2913 }
2914
2915 matches
2916}
2917
2918fn should_stop_search(
2919 truncated: &AtomicBool,
2920 total_matches: &AtomicUsize,
2921 stop_after: usize,
2922) -> bool {
2923 truncated.load(Ordering::Relaxed) && total_matches.load(Ordering::Relaxed) >= stop_after
2924}
2925
2926fn grep_scan_should_stop(
2927 stop_scan: Option<&Arc<AtomicBool>>,
2928 truncated: &AtomicBool,
2929 total_matches: &AtomicUsize,
2930 stop_after: usize,
2931 job_cancellation: Option<&crate::executor::JobCancellation>,
2932) -> bool {
2933 job_cancellation.is_some_and(|token| token.cancel_requested_before_commit())
2934 || stop_scan.is_some_and(|flag| flag.load(Ordering::Relaxed))
2935 || should_stop_search(truncated, total_matches, stop_after)
2936}
2937
2938fn signal_grep_scan_cap(
2939 stop_scan: Option<&Arc<AtomicBool>>,
2940 total_matches: &AtomicUsize,
2941 stop_after: usize,
2942) {
2943 if let Some(flag) = stop_scan {
2944 if total_matches.load(Ordering::Relaxed) >= stop_after {
2945 flag.store(true, Ordering::Relaxed);
2946 }
2947 }
2948}
2949
2950fn search_file_metadata(metadata: &fs::Metadata) -> SearchFileMetadata {
2951 SearchFileMetadata {
2952 size: metadata.len(),
2953 modified: metadata.modified().unwrap_or(UNIX_EPOCH),
2954 }
2955}
2956
2957fn metadata_for_indexed_content(path: &Path, size_hint: u64) -> SearchFileMetadata {
2958 fs::metadata(path)
2959 .ok()
2960 .map(|metadata| search_file_metadata(&metadata))
2961 .unwrap_or(SearchFileMetadata {
2962 size: size_hint,
2963 modified: UNIX_EPOCH,
2964 })
2965}
2966
2967fn prepare_search_path(path: &Path, max_file_size: u64) -> PreparedSearchPath {
2968 match read_search_corpus_file(path, max_file_size) {
2969 SearchCorpusEligibility::Eligible(file) => {
2970 PreparedSearchPath::Indexed(PreparedIndexedFile {
2971 metadata: file.metadata,
2972 content_hash: cache_freshness::hash_bytes(&file.bytes),
2973 trigram_map: trigram_filter_map(&file.bytes, true),
2974 })
2975 }
2976 SearchCorpusEligibility::Unindexed(metadata) => PreparedSearchPath::Unindexed(metadata),
2977 SearchCorpusEligibility::Skipped => PreparedSearchPath::Skipped,
2978 }
2979}
2980
2981pub(crate) fn read_search_corpus_file(path: &Path, max_file_size: u64) -> SearchCorpusEligibility {
2982 let metadata = match fs::metadata(path) {
2983 Ok(metadata) if metadata.is_file() => search_file_metadata(&metadata),
2984 _ => return SearchCorpusEligibility::Skipped,
2985 };
2986
2987 if is_binary_path(path, metadata.size) || metadata.size > max_file_size {
2988 return SearchCorpusEligibility::Unindexed(metadata);
2989 }
2990
2991 let bytes = match fs::read(path) {
2992 Ok(bytes) => bytes,
2993 Err(_) => return SearchCorpusEligibility::Skipped,
2994 };
2995 if is_binary_bytes(&bytes) {
2996 return SearchCorpusEligibility::Unindexed(metadata);
2997 }
2998
2999 SearchCorpusEligibility::Eligible(SearchCorpusFile {
3000 metadata,
3001 generated: crate::inspect::is_generated_file(path, path),
3002 bytes,
3003 })
3004}
3005
3006fn search_index_build_pool_size() -> usize {
3009 std::thread::available_parallelism()
3010 .map(|parallelism| parallelism.get())
3011 .unwrap_or(1)
3012 .div_ceil(2)
3013 .clamp(1, 8)
3014}
3015
3016#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3017struct SpillRecord {
3018 trigram: u32,
3019 file_id: u32,
3020 next_mask: u8,
3021 loc_mask: u8,
3022}
3023
3024struct CacheWritePlan {
3025 project_root: PathBuf,
3026 git_head: Option<String>,
3027 ignore_fingerprint: String,
3028 max_file_size: u64,
3029 files: Vec<FileEntry>,
3030 path_to_id: HashMap<PathBuf, u32>,
3031 unindexed_files: HashSet<u32>,
3032 file_trigram_count: Vec<u32>,
3033 id_map: Arc<HashMap<u32, u32>>,
3034}
3035
3036impl CacheWritePlan {
3037 fn from_index(index: &SearchIndex, git_head: Option<&str>) -> Option<Self> {
3038 let active_ids = index.active_file_ids();
3039 let mut id_map = HashMap::with_capacity(active_ids.len());
3040 for (new_id, old_id) in active_ids.iter().enumerate() {
3041 let new_id = u32::try_from(new_id).ok()?;
3042 id_map.insert(*old_id, new_id);
3043 }
3044
3045 let mut files = Vec::with_capacity(active_ids.len());
3046 let mut path_to_id = HashMap::with_capacity(active_ids.len());
3047 let mut unindexed_files = HashSet::new();
3048 let mut file_trigram_count = Vec::with_capacity(active_ids.len());
3049 for old_id in active_ids {
3050 let new_id = *id_map.get(&old_id)?;
3051 let file = index.files.get(old_id as usize)?.clone();
3052 if file.path.as_os_str().is_empty() {
3053 continue;
3054 }
3055 path_to_id.insert(file.path.clone(), new_id);
3056 if index.unindexed_files.contains(&old_id) {
3057 unindexed_files.insert(new_id);
3058 }
3059 file_trigram_count.push(
3060 index
3061 .file_trigram_count
3062 .get(old_id as usize)
3063 .copied()
3064 .unwrap_or(0),
3065 );
3066 files.push(file);
3067 }
3068
3069 Some(Self {
3070 project_root: index.project_root.clone(),
3071 git_head: git_head.map(ToOwned::to_owned),
3072 ignore_fingerprint: if index.ignore_rules_fingerprint.is_empty() {
3073 ignore_rules_fingerprint(&index.project_root)
3074 } else {
3075 index.ignore_rules_fingerprint.clone()
3076 },
3077 max_file_size: index.max_file_size,
3078 files,
3079 path_to_id,
3080 unindexed_files,
3081 file_trigram_count,
3082 id_map: Arc::new(id_map),
3083 })
3084 }
3085}
3086
3087trait PostingRecordSource {
3088 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>>;
3089}
3090
3091struct VecRecordSource {
3092 records: Vec<SpillRecord>,
3093 index: usize,
3094}
3095
3096impl VecRecordSource {
3097 fn new(records: Vec<SpillRecord>) -> Self {
3098 Self { records, index: 0 }
3099 }
3100}
3101
3102impl PostingRecordSource for VecRecordSource {
3103 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
3104 let record = self.records.get(self.index).copied();
3105 if record.is_some() {
3106 self.index += 1;
3107 }
3108 Ok(record)
3109 }
3110}
3111
3112struct SpillSegmentSource {
3113 reader: BufReader<File>,
3114 remaining_records: u64,
3115 current_trigram: u32,
3116 remaining_in_group: u32,
3117}
3118
3119impl SpillSegmentSource {
3120 fn open(path: &Path) -> std::io::Result<Self> {
3121 let mut reader = BufReader::new(File::open(path)?);
3122 let mut magic = [0u8; 8];
3123 reader.read_exact(&mut magic)?;
3124 if &magic != SPILL_MAGIC {
3125 return Err(std::io::Error::other("invalid search spill magic"));
3126 }
3127 if read_u32(&mut reader)? != INDEX_VERSION {
3128 return Err(std::io::Error::other("invalid search spill version"));
3129 }
3130 let remaining_records = read_u64(&mut reader)?;
3131 Ok(Self {
3132 reader,
3133 remaining_records,
3134 current_trigram: 0,
3135 remaining_in_group: 0,
3136 })
3137 }
3138}
3139
3140impl PostingRecordSource for SpillSegmentSource {
3141 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
3142 if self.remaining_records == 0 {
3143 return Ok(None);
3144 }
3145 if self.remaining_in_group == 0 {
3146 self.current_trigram = read_u32(&mut self.reader)?;
3147 self.remaining_in_group = read_u32(&mut self.reader)?;
3148 if self.remaining_in_group == 0 {
3149 return Err(std::io::Error::other("empty search spill group"));
3150 }
3151 }
3152 let mut file_id = [0u8; 4];
3153 self.reader.read_exact(&mut file_id)?;
3154 let mut masks = [0u8; 2];
3155 self.reader.read_exact(&mut masks)?;
3156 self.remaining_in_group -= 1;
3157 self.remaining_records -= 1;
3158 Ok(Some(SpillRecord {
3159 trigram: self.current_trigram,
3160 file_id: u32::from_le_bytes(file_id),
3161 next_mask: masks[0],
3162 loc_mask: masks[1],
3163 }))
3164 }
3165}
3166
3167struct BaseRecordSource {
3168 base: Arc<BasePostings>,
3169 id_map: Arc<HashMap<u32, u32>>,
3170 delta: Arc<DeltaState>,
3171 lookup_index: usize,
3172 current: Vec<SpillRecord>,
3173 current_index: usize,
3174}
3175
3176impl BaseRecordSource {
3177 fn new(
3178 base: Arc<BasePostings>,
3179 id_map: Arc<HashMap<u32, u32>>,
3180 delta: Arc<DeltaState>,
3181 ) -> Self {
3182 Self {
3183 base,
3184 id_map,
3185 delta,
3186 lookup_index: 0,
3187 current: Vec::new(),
3188 current_index: 0,
3189 }
3190 }
3191
3192 fn load_next_group(&mut self) -> std::io::Result<bool> {
3193 while let Some(entry) = self.base.lookup.get(self.lookup_index).copied() {
3194 self.lookup_index += 1;
3195 let postings = self.base.read_postings(entry)?;
3196 self.current.clear();
3197 self.current_index = 0;
3198 for posting in postings {
3199 if self.delta.superseded.contains(&posting.file_id) {
3200 continue;
3201 }
3202 let Some(mapped_file_id) = self.id_map.get(&posting.file_id).copied() else {
3203 continue;
3204 };
3205 self.current.push(SpillRecord {
3206 trigram: entry.trigram,
3207 file_id: mapped_file_id,
3208 next_mask: posting.next_mask,
3209 loc_mask: posting.loc_mask,
3210 });
3211 }
3212 if !self.current.is_empty() {
3213 return Ok(true);
3214 }
3215 }
3216 Ok(false)
3217 }
3218}
3219
3220impl PostingRecordSource for BaseRecordSource {
3221 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
3222 if self.current_index >= self.current.len() && !self.load_next_group()? {
3223 return Ok(None);
3224 }
3225 let record = self.current[self.current_index];
3226 self.current_index += 1;
3227 Ok(Some(record))
3228 }
3229}
3230
3231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3232struct HeapItem {
3233 record: SpillRecord,
3234 source_index: usize,
3235}
3236
3237impl Ord for HeapItem {
3238 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
3239 other
3240 .record
3241 .trigram
3242 .cmp(&self.record.trigram)
3243 .then_with(|| other.record.file_id.cmp(&self.record.file_id))
3244 .then_with(|| other.source_index.cmp(&self.source_index))
3245 }
3246}
3247
3248impl PartialOrd for HeapItem {
3249 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
3250 Some(self.cmp(other))
3251 }
3252}
3253
3254fn build_streaming_index(
3255 root: &Path,
3256 max_file_size: u64,
3257 cache_dir: &Path,
3258) -> std::io::Result<(SearchIndex, usize)> {
3259 fs::create_dir_all(cache_dir)?;
3260 sweep_stale_search_build_dirs(cache_dir);
3261 let project_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
3262 let ignore_fingerprint = ignore_rules_fingerprint(&project_root);
3263 let filters = PathFilters::default();
3264 let paths: Vec<PathBuf> = walk_project_files(&project_root, &filters);
3265 let pool_size = search_index_build_pool_size();
3266 let chunk_size = pool_size.saturating_mul(4).clamp(1, 32);
3267 let pool = rayon::ThreadPoolBuilder::new()
3268 .num_threads(pool_size)
3269 .thread_name(|index| format!("aft-search-build-{index}"))
3270 .stack_size(8 * 1024 * 1024)
3271 .build()
3272 .ok();
3273
3274 let spill_dir = create_spill_dir(cache_dir)?;
3275 let mut spill_paths = Vec::new();
3276 let mut spill_seq = 0usize;
3277 let mut block: Vec<SpillRecord> = Vec::new();
3278 let mut files = Vec::new();
3279 let mut path_to_id = HashMap::new();
3280 let mut unindexed_files = HashSet::new();
3281 let mut file_trigram_count = Vec::new();
3282 let mut indexed = 0usize;
3283
3284 let build_result = (|| -> std::io::Result<BasePostings> {
3285 for chunk in paths.chunks(chunk_size) {
3286 let prepare_chunk = || -> Vec<PreparedSearchPath> {
3287 chunk
3288 .par_iter()
3289 .map(|path| prepare_search_path(path, max_file_size))
3290 .collect()
3291 };
3292 let prepared = match &pool {
3293 Some(pool) => pool.install(prepare_chunk),
3294 None => prepare_chunk(),
3295 };
3296
3297 for (path, prepared) in chunk.iter().zip(prepared) {
3298 match prepared {
3299 PreparedSearchPath::Indexed(file) => {
3300 let file_id = u32::try_from(files.len())
3301 .map_err(|_| std::io::Error::other("too many files to index"))?;
3302 files.push(FileEntry {
3303 path: path.clone(),
3304 size: file.metadata.size,
3305 modified: file.metadata.modified,
3306 content_hash: file.content_hash,
3307 });
3308 path_to_id.insert(path.clone(), file_id);
3309 file_trigram_count.push(file.trigram_map.len() as u32);
3310 for (trigram, filter) in file.trigram_map {
3311 block.push(SpillRecord {
3312 trigram,
3313 file_id,
3314 next_mask: filter.next_mask,
3315 loc_mask: filter.loc_mask,
3316 });
3317 }
3318 indexed += 1;
3319 }
3320 PreparedSearchPath::Unindexed(metadata) => {
3321 let file_id = u32::try_from(files.len())
3322 .map_err(|_| std::io::Error::other("too many files to index"))?;
3323 files.push(FileEntry {
3324 path: path.clone(),
3325 size: metadata.size,
3326 modified: metadata.modified,
3327 content_hash: cache_freshness::zero_hash(),
3328 });
3329 path_to_id.insert(path.clone(), file_id);
3330 unindexed_files.insert(file_id);
3331 file_trigram_count.push(0);
3332 indexed += 1;
3333 }
3334 PreparedSearchPath::Skipped => {}
3335 }
3336
3337 let block_bytes = block.len().saturating_mul(SPILL_RECORD_ESTIMATED_BYTES);
3338 if block_bytes >= SPIMI_SOFT_LIMIT_BYTES || block_bytes >= SPIMI_HARD_LIMIT_BYTES {
3339 let path = flush_spill_segment(&spill_dir, spill_seq, &mut block)?;
3340 spill_paths.push(path);
3341 spill_seq += 1;
3342 }
3343 }
3344 }
3345
3346 block.sort_unstable_by_key(|record| (record.trigram, record.file_id));
3347 let mut sources: Vec<Box<dyn PostingRecordSource>> = Vec::new();
3348 for path in &spill_paths {
3349 sources.push(Box::new(SpillSegmentSource::open(path)?));
3350 }
3351 if !block.is_empty() {
3352 sources.push(Box::new(VecRecordSource::new(std::mem::take(&mut block))));
3353 }
3354
3355 let plan = CacheWritePlan {
3356 project_root: project_root.clone(),
3357 git_head: current_git_head(&project_root),
3358 ignore_fingerprint: ignore_fingerprint.clone(),
3359 max_file_size,
3360 files: files.clone(),
3361 path_to_id: path_to_id.clone(),
3362 unindexed_files: unindexed_files.clone(),
3363 file_trigram_count: file_trigram_count.clone(),
3364 id_map: Arc::new(
3365 (0..files.len())
3366 .filter_map(|id| {
3367 let id = u32::try_from(id).ok()?;
3368 Some((id, id))
3369 })
3370 .collect(),
3371 ),
3372 };
3373 write_cache_file_from_sources(cache_dir, &plan, &mut sources)
3374 })();
3375
3376 let _ = fs::remove_dir_all(&spill_dir);
3377 let base = build_result?;
3378 let base_file_count =
3379 u32::try_from(files.len()).map_err(|_| std::io::Error::other("too many files to index"))?;
3380 let git_head = current_git_head(&project_root);
3381 let index = SearchIndex {
3382 base: Some(Arc::new(base)),
3383 delta: Arc::new(DeltaState::default()),
3384 delta_file_trigrams: HashMap::new(),
3385 files: Arc::new(files),
3386 path_to_id: Arc::new(path_to_id),
3387 ready: false,
3388 build_denied: false,
3389 project_root,
3390 git_head,
3391 max_file_size,
3392 ignore_rules_fingerprint: ignore_fingerprint,
3393 file_trigram_count: Arc::new(file_trigram_count),
3394 unindexed_files: Arc::new(unindexed_files),
3395 base_file_count,
3396 delta_packed_bytes: 0,
3397 compaction_state: Arc::new(Mutex::new(CompactionState::default())),
3398 };
3399 Ok((index, indexed))
3400}
3401
3402fn write_cache_file_from_sources(
3403 cache_dir: &Path,
3404 plan: &CacheWritePlan,
3405 sources: &mut [Box<dyn PostingRecordSource>],
3406) -> std::io::Result<BasePostings> {
3407 fs::create_dir_all(cache_dir)?;
3408 sweep_stale_search_build_dirs(cache_dir);
3409 let cache_path = cache_dir.join("cache.bin");
3410 let tmp_cache = cache_dir.join(format!(
3411 "cache.bin.tmp.{}.{}",
3412 std::process::id(),
3413 SystemTime::now()
3414 .duration_since(UNIX_EPOCH)
3415 .unwrap_or(Duration::ZERO)
3416 .as_nanos()
3417 ));
3418
3419 let write_result = (|| -> std::io::Result<BasePostings> {
3420 let raw = OpenOptions::new()
3421 .write(true)
3422 .create_new(true)
3423 .open(&tmp_cache)?;
3424 let mut writer = BufWriter::new(raw);
3425 write_u32(&mut writer, CACHE_MAGIC)?;
3426 write_u32(&mut writer, INDEX_VERSION)?;
3427 let postings_len_patch = writer.stream_position()?;
3428 write_u64(&mut writer, 0)?;
3429
3430 let postings_section_start = writer.stream_position()?;
3431 let postings_header = build_postings_header_bytes(plan)?;
3432 writer.write_all(&postings_header)?;
3433 let postings_blob_len_patch = writer.stream_position()?;
3434 write_u64(&mut writer, 0)?;
3435 let postings_blob_start = writer.stream_position()?;
3436
3437 let (lookup_entries, postings_blob_len) = merge_sources_to_writer(sources, &mut writer)?;
3438 let extension = build_file_trigram_count_extension(&plan.file_trigram_count)?;
3439 writer.write_all(&extension)?;
3440 let postings_crc_end = writer.stream_position()?;
3441
3442 writer.flush()?;
3443 writer.seek(SeekFrom::Start(postings_blob_len_patch))?;
3444 write_u64(&mut writer, postings_blob_len)?;
3445 writer.flush()?;
3446
3447 let checksum = crc32_file_range(
3448 &tmp_cache,
3449 postings_section_start,
3450 postings_crc_end.saturating_sub(postings_section_start),
3451 )?;
3452 writer.seek(SeekFrom::Start(postings_crc_end))?;
3453 writer.write_all(&checksum.to_le_bytes())?;
3454 let postings_section_end = writer.stream_position()?;
3455 let postings_len_total = postings_section_end.saturating_sub(postings_section_start);
3456 writer.seek(SeekFrom::Start(postings_len_patch))?;
3457 write_u64(&mut writer, postings_len_total)?;
3458 writer.seek(SeekFrom::Start(postings_section_end))?;
3459
3460 let lookup_blob = build_lookup_section_bytes(&lookup_entries)?;
3461 writer.write_all(&lookup_blob)?;
3462 writer.flush()?;
3463 writer.get_ref().sync_all()?;
3464 drop(writer);
3465
3466 fs::rename(&tmp_cache, &cache_path)?;
3467 sync_parent_dir(&cache_path);
3468 let file = open_cache_file_read(&cache_path)?;
3469 Ok(BasePostings {
3470 file: Arc::new(file),
3471 postings_blob_start,
3472 postings_blob_len,
3473 lookup: Arc::new(lookup_entries),
3474 })
3475 })();
3476
3477 if write_result.is_err() {
3478 let _ = fs::remove_file(&tmp_cache);
3479 }
3480 write_result
3481}
3482
3483fn merge_sources_to_writer(
3484 sources: &mut [Box<dyn PostingRecordSource>],
3485 writer: &mut BufWriter<File>,
3486) -> std::io::Result<(Vec<LookupEntry>, u64)> {
3487 let mut heap = BinaryHeap::new();
3488 for (source_index, source) in sources.iter_mut().enumerate() {
3489 if let Some(record) = source.next_record()? {
3490 heap.push(HeapItem {
3491 record,
3492 source_index,
3493 });
3494 }
3495 }
3496
3497 let mut lookup_entries = Vec::new();
3498 let mut postings_blob_len = 0u64;
3499 let mut current_trigram: Option<u32> = None;
3500 let mut current_offset = 0u64;
3501 let mut current_count = 0u32;
3502
3503 while let Some(item) = heap.pop() {
3504 let record = item.record;
3505 if current_trigram != Some(record.trigram) {
3506 if let Some(trigram) = current_trigram {
3507 lookup_entries.push(LookupEntry {
3508 trigram,
3509 offset: current_offset,
3510 count: current_count,
3511 });
3512 }
3513 current_trigram = Some(record.trigram);
3514 current_offset = postings_blob_len;
3515 current_count = 0;
3516 }
3517
3518 writer.write_all(&record.file_id.to_le_bytes())?;
3519 writer.write_all(&[record.next_mask, record.loc_mask])?;
3520 postings_blob_len = postings_blob_len
3521 .checked_add(POSTING_BYTES as u64)
3522 .ok_or_else(|| std::io::Error::other("postings blob too large"))?;
3523 current_count = current_count
3524 .checked_add(1)
3525 .ok_or_else(|| std::io::Error::other("posting list too large"))?;
3526
3527 if let Some(next) = sources[item.source_index].next_record()? {
3528 heap.push(HeapItem {
3529 record: next,
3530 source_index: item.source_index,
3531 });
3532 }
3533 }
3534
3535 if let Some(trigram) = current_trigram {
3536 lookup_entries.push(LookupEntry {
3537 trigram,
3538 offset: current_offset,
3539 count: current_count,
3540 });
3541 }
3542
3543 Ok((lookup_entries, postings_blob_len))
3544}
3545
3546fn build_postings_header_bytes(plan: &CacheWritePlan) -> std::io::Result<Vec<u8>> {
3547 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
3548 writer.write_all(INDEX_MAGIC)?;
3549 write_u32(&mut writer, INDEX_VERSION)?;
3550
3551 let head = plan.git_head.as_deref().unwrap_or_default();
3552 let root = plan.project_root.to_string_lossy();
3553 let head_len = u32::try_from(head.len())
3554 .map_err(|_| std::io::Error::other("git head too large to cache"))?;
3555 let root_len = u32::try_from(root.len())
3556 .map_err(|_| std::io::Error::other("project root too large to cache"))?;
3557 let ignore_fingerprint_len = u32::try_from(plan.ignore_fingerprint.len())
3558 .map_err(|_| std::io::Error::other("ignore fingerprint too large to cache"))?;
3559 let file_count = u32::try_from(plan.files.len())
3560 .map_err(|_| std::io::Error::other("too many files to cache"))?;
3561
3562 write_u32(&mut writer, head_len)?;
3563 write_u32(&mut writer, root_len)?;
3564 write_u32(&mut writer, ignore_fingerprint_len)?;
3565 write_u64(&mut writer, plan.max_file_size)?;
3566 write_u32(&mut writer, file_count)?;
3567 writer.write_all(head.as_bytes())?;
3568 writer.write_all(root.as_bytes())?;
3569 writer.write_all(plan.ignore_fingerprint.as_bytes())?;
3570
3571 for (file_id, file) in plan.files.iter().enumerate() {
3572 let file_id =
3573 u32::try_from(file_id).map_err(|_| std::io::Error::other("too many files to cache"))?;
3574 let path = cache_relative_path(&plan.project_root, &file.path)
3575 .or_else(|| {
3576 fs::canonicalize(&file.path)
3577 .ok()
3578 .and_then(|canonical| cache_relative_path(&plan.project_root, &canonical))
3579 })
3580 .ok_or_else(|| {
3581 std::io::Error::other(format!(
3582 "refusing to cache path outside project root: {}",
3583 file.path.display()
3584 ))
3585 })?;
3586 let path = path.to_string_lossy();
3587 let path_len = u32::try_from(path.len())
3588 .map_err(|_| std::io::Error::other("cached path too large"))?;
3589 let modified = file
3590 .modified
3591 .duration_since(UNIX_EPOCH)
3592 .unwrap_or(Duration::ZERO);
3593 let unindexed = if plan.unindexed_files.contains(&file_id) {
3594 1u8
3595 } else {
3596 0u8
3597 };
3598
3599 writer.write_all(&[unindexed])?;
3600 write_u32(&mut writer, path_len)?;
3601 write_u64(&mut writer, file.size)?;
3602 write_u64(&mut writer, modified.as_secs())?;
3603 write_u32(&mut writer, modified.subsec_nanos())?;
3604 writer.write_all(file.content_hash.as_bytes())?;
3605 writer.write_all(path.as_bytes())?;
3606 }
3607
3608 writer.flush()?;
3609 Ok(writer
3610 .into_inner()
3611 .map_err(|error| std::io::Error::other(error.to_string()))?
3612 .into_inner())
3613}
3614
3615fn build_lookup_section_bytes(lookup_entries: &[LookupEntry]) -> std::io::Result<Vec<u8>> {
3616 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
3617 let entry_count = u32::try_from(lookup_entries.len())
3618 .map_err(|_| std::io::Error::other("too many lookup entries to cache"))?;
3619 writer.write_all(LOOKUP_MAGIC)?;
3620 write_u32(&mut writer, INDEX_VERSION)?;
3621 write_u32(&mut writer, entry_count)?;
3622 for entry in lookup_entries {
3623 write_u32(&mut writer, entry.trigram)?;
3624 write_u64(&mut writer, entry.offset)?;
3625 write_u32(&mut writer, entry.count)?;
3626 }
3627 writer.flush()?;
3628 let mut lookup_blob = writer
3629 .into_inner()
3630 .map_err(|error| std::io::Error::other(error.to_string()))?
3631 .into_inner();
3632 let checksum = crc32fast::hash(&lookup_blob);
3633 lookup_blob.extend_from_slice(&checksum.to_le_bytes());
3634 Ok(lookup_blob)
3635}
3636
3637fn build_file_trigram_count_extension(counts: &[u32]) -> std::io::Result<Vec<u8>> {
3638 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
3639 writer.write_all(FILE_TRIGRAM_COUNT_MAGIC)?;
3640 write_u32(&mut writer, INDEX_VERSION)?;
3641 write_u32(
3642 &mut writer,
3643 u32::try_from(counts.len())
3644 .map_err(|_| std::io::Error::other("too many file trigram counts"))?,
3645 )?;
3646 for count in counts {
3647 write_u32(&mut writer, *count)?;
3648 }
3649 writer.flush()?;
3650 Ok(writer
3651 .into_inner()
3652 .map_err(|error| std::io::Error::other(error.to_string()))?
3653 .into_inner())
3654}
3655
3656fn flush_spill_segment(
3657 spill_dir: &Path,
3658 seq: usize,
3659 block: &mut Vec<SpillRecord>,
3660) -> std::io::Result<PathBuf> {
3661 if block.is_empty() {
3662 return Err(std::io::Error::other(
3663 "refusing to write empty search spill",
3664 ));
3665 }
3666 block.sort_unstable_by_key(|record| (record.trigram, record.file_id));
3667 let path = spill_dir.join(format!("segment.{seq:06}.bin"));
3668 let mut writer = BufWriter::new(File::create(&path)?);
3669 writer.write_all(SPILL_MAGIC)?;
3670 write_u32(&mut writer, INDEX_VERSION)?;
3671 write_u64(
3672 &mut writer,
3673 u64::try_from(block.len()).map_err(|_| std::io::Error::other("search spill too large"))?,
3674 )?;
3675
3676 let mut index = 0usize;
3677 while index < block.len() {
3678 let trigram = block[index].trigram;
3679 let group_start = index;
3680 while index < block.len() && block[index].trigram == trigram {
3681 index += 1;
3682 }
3683 write_u32(&mut writer, trigram)?;
3684 write_u32(
3685 &mut writer,
3686 u32::try_from(index - group_start)
3687 .map_err(|_| std::io::Error::other("search spill group too large"))?,
3688 )?;
3689 for record in &block[group_start..index] {
3690 writer.write_all(&record.file_id.to_le_bytes())?;
3691 writer.write_all(&[record.next_mask, record.loc_mask])?;
3692 }
3693 }
3694 writer.flush()?;
3695 writer.get_ref().sync_all()?;
3696 block.clear();
3697 Ok(path)
3698}
3699
3700fn create_spill_dir(cache_dir: &Path) -> std::io::Result<PathBuf> {
3701 let dir = cache_dir.join(format!(
3702 "search-build.tmp.{}.{}",
3703 std::process::id(),
3704 SystemTime::now()
3705 .duration_since(UNIX_EPOCH)
3706 .unwrap_or(Duration::ZERO)
3707 .as_nanos()
3708 ));
3709 fs::create_dir_all(&dir)?;
3710 Ok(dir)
3711}
3712
3713fn sweep_stale_search_build_dirs(cache_dir: &Path) {
3714 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(cache_dir) else {
3718 return;
3719 };
3720 let Ok(entries) = fs::read_dir(cache_dir) else {
3721 return;
3722 };
3723 for entry in entries.flatten() {
3724 let file_name = entry.file_name();
3725 let path = entry.path();
3726 if file_name.to_string_lossy().starts_with("search-build.tmp.") {
3727 if boundary.should_descend(&path).unwrap_or(false) {
3728 let _ = fs::remove_dir_all(path);
3729 } else {
3730 crate::slog_warn!(
3731 "search-index sweep skipped foreign filesystem mount {}",
3732 path.display()
3733 );
3734 }
3735 }
3736 }
3737}
3738
3739fn transient_search_cache_dir(root: &Path) -> PathBuf {
3740 std::env::temp_dir().join(format!(
3741 "{TRANSIENT_SEARCH_CACHE_PREFIX}{}.{}",
3742 artifact_cache_key(root),
3743 std::process::id()
3744 ))
3745}
3746
3747fn transient_search_cache_build_lock(cache_dir: &Path) -> Arc<Mutex<()>> {
3751 let locks = TRANSIENT_SEARCH_CACHE_BUILD_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
3752 let mut locks = locks
3753 .lock()
3754 .unwrap_or_else(std::sync::PoisonError::into_inner);
3755 if let Some(lock) = locks.get(cache_dir).and_then(Weak::upgrade) {
3756 return lock;
3757 }
3758 if locks.len() >= 1_024 {
3759 locks.retain(|_, lock| lock.strong_count() > 0);
3760 }
3761 let lock = Arc::new(Mutex::new(()));
3762 locks.insert(cache_dir.to_path_buf(), Arc::downgrade(&lock));
3763 lock
3764}
3765
3766fn truncate_transient_search_cache_dir(cache_dir: &Path) -> std::io::Result<()> {
3770 fs::create_dir_all(cache_dir)?;
3771 for entry in fs::read_dir(cache_dir)? {
3772 let entry = entry?;
3773 let file_type = entry.file_type()?;
3774 let path = entry.path();
3775 if file_type.is_dir() {
3776 fs::remove_dir_all(path)?;
3777 } else {
3778 fs::remove_file(path)?;
3779 }
3780 }
3781 Ok(())
3782}
3783
3784pub(crate) fn sweep_transient_search_cache_dirs() {
3789 let root = std::env::temp_dir();
3790 let summary = sweep_transient_search_cache_dirs_with_limits(
3791 &root,
3792 TRANSIENT_SEARCH_CACHE_MIN_AGE,
3793 TRANSIENT_SEARCH_CACHE_SWEEP_BUDGET,
3794 TRANSIENT_SEARCH_CACHE_SWEEP_LIMIT,
3795 );
3796 crate::slog_info!(
3797 "transient search cache sweep root={} scanned={} removed={} bytes={} skipped_fresh={} skipped_unreadable={} budget_exhausted={}",
3798 root.display(),
3799 summary.scanned,
3800 summary.removed,
3801 summary.bytes,
3802 summary.skipped_fresh,
3803 summary.skipped_unreadable,
3804 summary.budget_exhausted
3805 );
3806}
3807
3808fn sweep_transient_search_cache_dirs_with_limits(
3809 root: &Path,
3810 min_age: Duration,
3811 wall_clock_budget: Duration,
3812 entry_limit: usize,
3813) -> TransientSearchCacheSweepSummary {
3814 let deadline = Instant::now() + wall_clock_budget;
3815 let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root) {
3816 Ok(boundary) => boundary,
3817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3818 return TransientSearchCacheSweepSummary::default();
3819 }
3820 Err(error) => {
3821 crate::slog_warn!(
3822 "cannot establish filesystem boundary for transient search cache sweep {}: {}",
3823 root.display(),
3824 error
3825 );
3826 return TransientSearchCacheSweepSummary {
3827 skipped_unreadable: 1,
3828 ..TransientSearchCacheSweepSummary::default()
3829 };
3830 }
3831 };
3832 let mut entries = match fs::read_dir(root) {
3833 Ok(entries) => entries
3834 .filter_map(Result::ok)
3835 .filter_map(|entry| {
3836 let file_type = entry.file_type().ok()?;
3837 let name = entry.file_name().to_string_lossy().into_owned();
3838 (file_type.is_dir() && parse_transient_search_cache_name(&name).is_some())
3839 .then(|| (name, entry.path()))
3840 })
3841 .collect::<Vec<_>>(),
3842 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
3843 Err(error) => {
3844 crate::slog_warn!(
3845 "cannot read transient search cache sweep directory {}: {}",
3846 root.display(),
3847 error
3848 );
3849 return TransientSearchCacheSweepSummary {
3850 skipped_unreadable: 1,
3851 ..TransientSearchCacheSweepSummary::default()
3852 };
3853 }
3854 };
3855 entries.sort_by(|left, right| left.0.cmp(&right.0));
3856
3857 let cursor_store =
3858 TRANSIENT_SEARCH_CACHE_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
3859 let last_name = cursor_store
3860 .lock()
3861 .ok()
3862 .and_then(|cursors| cursors.get(root).cloned());
3863 if let Some(start) = last_name
3864 .as_deref()
3865 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
3866 {
3867 entries.rotate_left(start);
3868 }
3869
3870 let now = SystemTime::now();
3871 let mut summary = TransientSearchCacheSweepSummary::default();
3872 let mut cursor_name = last_name;
3873 for (processed, (name, path)) in entries.into_iter().enumerate() {
3874 if processed >= entry_limit || Instant::now() >= deadline {
3875 summary.budget_exhausted = true;
3876 break;
3877 }
3878 summary.scanned += 1;
3879 cursor_name = Some(name.clone());
3880 if !boundary.should_descend(&path).unwrap_or(false) {
3881 summary.skipped_unreadable += 1;
3882 continue;
3883 }
3884 let modified = match fs::metadata(&path).and_then(|metadata| metadata.modified()) {
3885 Ok(modified) => modified,
3886 Err(_) => {
3887 summary.skipped_unreadable += 1;
3888 continue;
3889 }
3890 };
3891 if now.duration_since(modified).unwrap_or(Duration::ZERO) <= min_age {
3892 summary.skipped_fresh += 1;
3893 continue;
3894 }
3895 let bytes = match transient_search_cache_dir_bytes(&path, &boundary, deadline) {
3896 TransientSearchCacheWalk::Complete(bytes) => bytes,
3897 TransientSearchCacheWalk::BudgetExceeded => {
3898 summary.budget_exhausted = true;
3899 break;
3900 }
3901 TransientSearchCacheWalk::Failed => {
3902 summary.skipped_unreadable += 1;
3903 continue;
3904 }
3905 };
3906 let Some(parsed) = parse_transient_search_cache_name(&name) else {
3907 summary.skipped_unreadable += 1;
3908 continue;
3909 };
3910 match fs::remove_dir_all(&path) {
3911 Ok(()) => {
3912 summary.removed += 1;
3913 summary.bytes = summary.bytes.saturating_add(bytes);
3914 crate::slog_info!(
3915 "transient search cache sweep reaped dir={} key={} pid={} bytes={}",
3916 path.display(),
3917 parsed.key,
3918 parsed.pid,
3919 bytes
3920 );
3921 }
3922 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !path.exists() => {
3923 summary.removed += 1;
3924 summary.bytes = summary.bytes.saturating_add(bytes);
3925 crate::slog_info!(
3926 "transient search cache sweep reaped dir={} key={} pid={} bytes={}",
3927 path.display(),
3928 parsed.key,
3929 parsed.pid,
3930 bytes
3931 );
3932 }
3933 Err(_) => summary.skipped_unreadable += 1,
3934 }
3935 }
3936
3937 if let Ok(mut cursors) = cursor_store.lock() {
3938 if summary.budget_exhausted {
3939 if let Some(cursor_name) = cursor_name {
3940 cursors.insert(root.to_path_buf(), cursor_name);
3941 }
3942 } else {
3943 cursors.remove(root);
3944 }
3945 }
3946 if summary.removed > 0 {
3947 crate::fs_lock::sync_parent(root);
3948 }
3949 summary
3950}
3951
3952fn parse_transient_search_cache_name(name: &str) -> Option<TransientSearchCacheName> {
3953 let mut parts = name.strip_prefix(TRANSIENT_SEARCH_CACHE_PREFIX)?.split('.');
3954 let key = parts.next()?;
3955 if !artifact_key_looks_valid(key) {
3956 return None;
3957 }
3958 let pid = parts.next()?.parse::<u32>().ok().filter(|pid| *pid != 0)?;
3959 match (parts.next(), parts.next()) {
3960 (None, None) => {}
3961 (Some(nanos), None) if nanos.parse::<u128>().is_ok() => {}
3962 _ => return None,
3963 }
3964 Some(TransientSearchCacheName {
3965 key: key.to_owned(),
3966 pid,
3967 })
3968}
3969
3970fn transient_search_cache_dir_bytes(
3971 directory: &Path,
3972 boundary: &crate::walk_boundary::DeviceBoundary,
3973 deadline: Instant,
3974) -> TransientSearchCacheWalk {
3975 if Instant::now() >= deadline {
3976 return TransientSearchCacheWalk::BudgetExceeded;
3977 }
3978 let entries = match fs::read_dir(directory) {
3979 Ok(entries) => entries,
3980 Err(_) => return TransientSearchCacheWalk::Failed,
3981 };
3982 let mut bytes = 0u64;
3983 for entry in entries {
3984 if Instant::now() >= deadline {
3985 return TransientSearchCacheWalk::BudgetExceeded;
3986 }
3987 let entry = match entry {
3988 Ok(entry) => entry,
3989 Err(_) => return TransientSearchCacheWalk::Failed,
3990 };
3991 let file_type = match entry.file_type() {
3992 Ok(file_type) => file_type,
3993 Err(_) => return TransientSearchCacheWalk::Failed,
3994 };
3995 let path = entry.path();
3996 if file_type.is_file() {
3997 let metadata = match entry.metadata() {
3998 Ok(metadata) => metadata,
3999 Err(_) => return TransientSearchCacheWalk::Failed,
4000 };
4001 bytes = bytes.saturating_add(metadata.len());
4002 } else if file_type.is_dir() {
4003 if !boundary.should_descend(&path).unwrap_or(false) {
4004 return TransientSearchCacheWalk::Failed;
4005 }
4006 match transient_search_cache_dir_bytes(&path, boundary, deadline) {
4007 TransientSearchCacheWalk::Complete(nested) => {
4008 bytes = bytes.saturating_add(nested);
4009 }
4010 other => return other,
4011 }
4012 } else {
4013 return TransientSearchCacheWalk::Failed;
4014 }
4015 }
4016 TransientSearchCacheWalk::Complete(bytes)
4017}
4018
4019fn read_file_trigram_count_extension(
4020 base: &BasePostings,
4021 extension_start: u64,
4022 postings_body_end: u64,
4023 file_count: usize,
4024) -> std::io::Result<Option<Vec<u32>>> {
4025 if extension_start >= postings_body_end {
4026 return Ok(None);
4027 }
4028 let extension_len = postings_body_end - extension_start;
4029 if extension_len < 16 {
4030 return Ok(None);
4031 }
4032 let mut header = [0u8; 16];
4033 pread_exact(&base.file, extension_start, &mut header)?;
4034 if &header[..8] != FILE_TRIGRAM_COUNT_MAGIC {
4035 return Ok(None);
4036 }
4037 let version = u32::from_le_bytes([header[8], header[9], header[10], header[11]]);
4038 if version != INDEX_VERSION {
4039 return Err(std::io::Error::other("invalid file trigram count version"));
4040 }
4041 let count = u32::from_le_bytes([header[12], header[13], header[14], header[15]]) as usize;
4042 if count != file_count {
4043 return Err(std::io::Error::other("file trigram count length mismatch"));
4044 }
4045 let counts_len = count
4046 .checked_mul(4)
4047 .ok_or_else(|| std::io::Error::other("file trigram count extension too large"))?;
4048 if 16u64 + counts_len as u64 > extension_len {
4049 return Err(std::io::Error::other(
4050 "truncated file trigram count extension",
4051 ));
4052 }
4053 let mut bytes = vec![0u8; counts_len];
4054 pread_exact(&base.file, extension_start + 16, &mut bytes)?;
4055 let mut counts = Vec::with_capacity(count);
4056 for chunk in bytes.chunks_exact(4) {
4057 counts.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
4058 }
4059 Ok(Some(counts))
4060}
4061
4062fn compute_file_trigram_counts_from_base(
4063 base: &BasePostings,
4064 file_count: usize,
4065 borrowed_load_budget: Option<&BorrowedIndexLoadBudget>,
4066) -> std::io::Result<Vec<u32>> {
4067 let mut counts = vec![0u32; file_count];
4068 for (entry_index, entry) in base.lookup.iter().copied().enumerate() {
4069 if borrowed_load_budget.is_some_and(|budget| !budget.checkpoint_at(entry_index)) {
4070 return Err(std::io::Error::other("borrowed index load stopped"));
4071 }
4072 for posting in base.read_postings(entry)? {
4073 let Some(count) = counts.get_mut(posting.file_id as usize) else {
4074 return Err(std::io::Error::other("posting references missing file"));
4075 };
4076 *count = count.saturating_add(1);
4077 }
4078 }
4079 Ok(counts)
4080}
4081
4082fn ensure_count_slot(counts: &mut Vec<u32>, file_id: u32) {
4083 let len = file_id as usize + 1;
4084 if counts.len() < len {
4085 counts.resize(len, 0);
4086 }
4087}
4088
4089fn reader_has_remaining<R: Seek>(
4090 reader: &mut R,
4091 absolute_end: u64,
4092 len: usize,
4093) -> std::io::Result<bool> {
4094 let position = reader.stream_position()?;
4095 Ok(position <= absolute_end && (len as u64) <= absolute_end - position)
4096}
4097
4098fn crc32_file_range(path: &Path, start: u64, len: u64) -> std::io::Result<u32> {
4099 let mut file = File::open(path)?;
4100 file.seek(SeekFrom::Start(start))?;
4101 let mut hasher = crc32fast::Hasher::new();
4102 let mut remaining = len;
4103 let mut buffer = vec![0u8; 1024 * 1024];
4104 while remaining > 0 {
4105 let read_len = buffer.len().min(remaining as usize);
4106 let bytes_read = file.read(&mut buffer[..read_len])?;
4107 if bytes_read == 0 {
4108 return Err(std::io::Error::new(
4109 std::io::ErrorKind::UnexpectedEof,
4110 "truncated cache while checksumming",
4111 ));
4112 }
4113 hasher.update(&buffer[..bytes_read]);
4114 remaining -= bytes_read as u64;
4115 }
4116 Ok(hasher.finalize())
4117}
4118
4119fn sync_parent_dir(path: &Path) {
4120 if let Some(parent) = path.parent() {
4121 if let Ok(dir) = File::open(parent) {
4122 let _ = dir.sync_all();
4123 }
4124 }
4125}
4126
4127fn open_cache_file_read(path: &Path) -> std::io::Result<File> {
4128 let mut options = OpenOptions::new();
4129 options.read(true);
4130 #[cfg(windows)]
4131 {
4132 use std::os::windows::fs::OpenOptionsExt;
4133 const FILE_SHARE_READ: u32 = 0x0000_0001;
4134 const FILE_SHARE_WRITE: u32 = 0x0000_0002;
4135 const FILE_SHARE_DELETE: u32 = 0x0000_0004;
4136 options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
4137 }
4138 options.open(path)
4139}
4140
4141#[cfg(unix)]
4142fn pread_exact(file: &File, mut offset: u64, mut buffer: &mut [u8]) -> std::io::Result<()> {
4143 use std::os::unix::fs::FileExt;
4144 while !buffer.is_empty() {
4145 let bytes_read = file.read_at(buffer, offset)?;
4146 if bytes_read == 0 {
4147 return Err(std::io::Error::new(
4148 std::io::ErrorKind::UnexpectedEof,
4149 "short pread from search cache",
4150 ));
4151 }
4152 offset += bytes_read as u64;
4153 let (_, rest) = buffer.split_at_mut(bytes_read);
4154 buffer = rest;
4155 }
4156 Ok(())
4157}
4158
4159#[cfg(windows)]
4160fn pread_exact(file: &File, mut offset: u64, mut buffer: &mut [u8]) -> std::io::Result<()> {
4161 use std::os::windows::fs::FileExt;
4162 while !buffer.is_empty() {
4163 let bytes_read = file.seek_read(buffer, offset)?;
4164 if bytes_read == 0 {
4165 return Err(std::io::Error::new(
4166 std::io::ErrorKind::UnexpectedEof,
4167 "short pread from search cache",
4168 ));
4169 }
4170 offset += bytes_read as u64;
4171 let (_, rest) = buffer.split_at_mut(bytes_read);
4172 buffer = rest;
4173 }
4174 Ok(())
4175}
4176
4177fn insert_delta_posting(postings: &mut Vec<Posting>, posting: Posting) {
4181 if postings
4182 .last()
4183 .is_none_or(|last| last.file_id < posting.file_id)
4184 {
4185 postings.push(posting);
4186 return;
4187 }
4188
4189 let insertion_index = postings.partition_point(|existing| existing.file_id < posting.file_id);
4190 debug_assert!(postings
4191 .get(insertion_index)
4192 .is_none_or(|existing| existing.file_id != posting.file_id));
4193 postings.insert(insertion_index, posting);
4194}
4195
4196#[cfg(test)]
4197fn insert_delta_posting_full_sort_reference(postings: &mut Vec<Posting>, posting: Posting) {
4198 postings.push(posting);
4199 if postings.len() > 1
4200 && postings[postings.len() - 2].file_id > postings[postings.len() - 1].file_id
4201 {
4202 postings.sort_unstable_by_key(|posting| posting.file_id);
4203 }
4204}
4205
4206fn intersect_sorted_ids(left: &[u32], right: &[u32]) -> Vec<u32> {
4207 let mut merged = Vec::with_capacity(left.len().min(right.len()));
4208 let mut left_index = 0;
4209 let mut right_index = 0;
4210
4211 while left_index < left.len() && right_index < right.len() {
4212 if (left_index + right_index) % BORROWED_INDEX_CHECKPOINT_INTERVAL == 0
4213 && crate::executor::current_job_cancelled()
4214 {
4215 break;
4216 }
4217 match left[left_index].cmp(&right[right_index]) {
4218 std::cmp::Ordering::Less => left_index += 1,
4219 std::cmp::Ordering::Greater => right_index += 1,
4220 std::cmp::Ordering::Equal => {
4221 merged.push(left[left_index]);
4222 left_index += 1;
4223 right_index += 1;
4224 }
4225 }
4226 }
4227
4228 merged
4229}
4230
4231fn union_sorted_ids(left: &[u32], right: &[u32]) -> Vec<u32> {
4232 let mut merged = Vec::with_capacity(left.len() + right.len());
4233 let mut left_index = 0;
4234 let mut right_index = 0;
4235
4236 while left_index < left.len() && right_index < right.len() {
4237 if (left_index + right_index) % BORROWED_INDEX_CHECKPOINT_INTERVAL == 0
4238 && crate::executor::current_job_cancelled()
4239 {
4240 break;
4241 }
4242 match left[left_index].cmp(&right[right_index]) {
4243 std::cmp::Ordering::Less => {
4244 merged.push(left[left_index]);
4245 left_index += 1;
4246 }
4247 std::cmp::Ordering::Greater => {
4248 merged.push(right[right_index]);
4249 right_index += 1;
4250 }
4251 std::cmp::Ordering::Equal => {
4252 merged.push(left[left_index]);
4253 left_index += 1;
4254 right_index += 1;
4255 }
4256 }
4257 }
4258
4259 if !crate::executor::current_job_cancelled() {
4260 merged.extend_from_slice(&left[left_index..]);
4261 merged.extend_from_slice(&right[right_index..]);
4262 }
4263 merged
4264}
4265
4266pub(crate) fn decompose_grep_pattern(pattern: &CompiledPattern) -> RegexQuery {
4267 let raw_pattern = pattern.raw_pattern_for_trigrams();
4268 match pattern {
4269 CompiledPattern::Regex {
4270 case_insensitive: true,
4271 ..
4272 } => {
4273 decompose_regex(&format!("(?i:{raw_pattern})"))
4277 }
4278 _ => decompose_regex(&raw_pattern),
4279 }
4280}
4281
4282pub fn decompose_regex(pattern: &str) -> RegexQuery {
4283 let hir = match regex_syntax::parse(pattern) {
4284 Ok(hir) => hir,
4285 Err(_) => return RegexQuery::default(),
4286 };
4287
4288 let build = build_query(&hir);
4289 build.into_query()
4290}
4291
4292pub fn pack_trigram(a: u8, b: u8, c: u8) -> u32 {
4293 ((a as u32) << 16) | ((b as u32) << 8) | c as u32
4294}
4295
4296pub fn normalize_char(c: u8) -> u8 {
4297 c.to_ascii_lowercase()
4298}
4299
4300fn scan_trigrams(content: &[u8], mut visit: impl FnMut(u32, u8, usize)) {
4301 if content.len() < 3 {
4302 return;
4303 }
4304
4305 for start in 0..=content.len() - 3 {
4306 let trigram = pack_trigram(
4307 normalize_char(content[start]),
4308 normalize_char(content[start + 1]),
4309 normalize_char(content[start + 2]),
4310 );
4311 let next_char = content.get(start + 3).copied().unwrap_or(EOF_SENTINEL);
4312 visit(trigram, next_char, start);
4313 }
4314}
4315
4316pub fn extract_trigrams(content: &[u8]) -> Vec<(u32, u8, usize)> {
4317 let mut trigrams = Vec::with_capacity(content.len().saturating_sub(2));
4318 scan_trigrams(content, |trigram, next_char, position| {
4319 trigrams.push((trigram, next_char, position));
4320 });
4321 trigrams
4322}
4323
4324fn trigram_filter_map(content: &[u8], include_eof_next_char: bool) -> BTreeMap<u32, PostingFilter> {
4325 let mut filters: BTreeMap<u32, PostingFilter> = BTreeMap::new();
4326 scan_trigrams(content, |trigram, next_char, position| {
4327 let entry = filters.entry(trigram).or_default();
4328 if include_eof_next_char || next_char != EOF_SENTINEL {
4329 entry.next_mask |= mask_for_next_char(next_char);
4330 }
4331 entry.loc_mask |= mask_for_position(position);
4332 });
4333 filters
4334}
4335
4336pub fn query_trigrams_from_tokens(tokens: &[&str]) -> Vec<u32> {
4337 let mut seen = HashSet::new();
4338 let mut out = Vec::new();
4339 for token in tokens {
4340 scan_trigrams(token.as_bytes(), |trigram, _, _| {
4341 if seen.insert(trigram) {
4342 out.push(trigram);
4343 }
4344 });
4345 }
4346 out
4347}
4348
4349pub fn lexical_score(index: &SearchIndex, query_trigrams: &[u32], file_id: u32) -> f32 {
4350 lexical_score_snapshot(&index.snapshot(), query_trigrams, file_id)
4351}
4352
4353fn materialize_query_postings(
4354 index: &SearchIndexSnapshot,
4355 query_trigrams: &[u32],
4356) -> HashMap<u32, Vec<u32>> {
4357 let mut postings_by_trigram = HashMap::with_capacity(query_trigrams.len());
4358 for &trigram in query_trigrams {
4359 postings_by_trigram
4360 .entry(trigram)
4361 .or_insert_with(|| index.postings_for_trigram(trigram, None));
4362 }
4363 postings_by_trigram
4364}
4365
4366fn lexical_score_snapshot(
4367 index: &SearchIndexSnapshot,
4368 query_trigrams: &[u32],
4369 file_id: u32,
4370) -> f32 {
4371 let postings_by_trigram = materialize_query_postings(index, query_trigrams);
4372 lexical_score_from_postings(index, query_trigrams, &postings_by_trigram, file_id)
4373}
4374
4375fn lexical_score_from_postings(
4376 index: &SearchIndexSnapshot,
4377 query_trigrams: &[u32],
4378 postings_by_trigram: &HashMap<u32, Vec<u32>>,
4379 file_id: u32,
4380) -> f32 {
4381 if query_trigrams.is_empty() {
4382 return 0.0;
4383 }
4384
4385 let mut hits = 0u32;
4386 for &trigram in query_trigrams {
4387 if postings_by_trigram
4388 .get(&trigram)
4389 .is_some_and(|postings| postings.binary_search(&file_id).is_ok())
4390 {
4391 hits += 1;
4392 }
4393 }
4394
4395 if hits == 0 {
4396 return 0.0;
4397 }
4398
4399 let file_trigram_count = index
4400 .file_trigram_count
4401 .get(file_id as usize)
4402 .copied()
4403 .unwrap_or(1)
4404 .max(1) as f32;
4405 (hits as f32) / (1.0 + file_trigram_count.ln())
4406}
4407
4408#[cfg(test)]
4409fn lexical_rank_with_stats_reference(
4410 index: &SearchIndexSnapshot,
4411 query_trigrams: &[u32],
4412 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
4413 max_files: usize,
4414) -> LexicalRankResult {
4415 if query_trigrams.is_empty() || max_files == 0 {
4416 return LexicalRankResult::default();
4417 }
4418
4419 let mut non_zero: Vec<(u32, usize)> = query_trigrams
4420 .iter()
4421 .filter_map(|trigram| {
4422 let posting_count = index.posting_count(*trigram);
4423 (posting_count > 0).then_some((*trigram, posting_count))
4424 })
4425 .collect();
4426 if non_zero.is_empty() {
4427 return LexicalRankResult::default();
4428 }
4429
4430 non_zero.sort_unstable_by_key(|(_, posting_count)| *posting_count);
4431 let selected_count = non_zero.len().min(3);
4432 let candidate_cap = if selected_count == 3 { 200 } else { 500 };
4433
4434 let mut candidate_ids = BTreeSet::new();
4435 for (trigram, _) in non_zero.iter().take(selected_count) {
4436 candidate_ids.extend(index.postings_for_trigram(*trigram, None));
4437 }
4438 let pre_filter_candidate_count = candidate_ids.len();
4439 let engine_capped = pre_filter_candidate_count > candidate_cap;
4440 let filtered_candidates = candidate_ids
4441 .into_iter()
4442 .filter_map(|file_id| {
4443 index
4444 .files
4445 .get(file_id as usize)
4446 .map(|entry| (file_id, entry))
4447 })
4448 .filter(|(_, entry)| {
4449 candidate_filter
4450 .map(|filter| filter(&entry.path))
4451 .unwrap_or(true)
4452 })
4453 .collect::<Vec<_>>();
4454
4455 let mut ranked = Vec::new();
4456 for (file_id, entry) in filtered_candidates.into_iter().take(candidate_cap) {
4457 let score = lexical_score_snapshot_reference(index, query_trigrams, file_id);
4458 if score > 0.0 {
4459 ranked.push((entry.path.clone(), score));
4460 }
4461 }
4462
4463 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
4464 ranked.truncate(max_files);
4465 LexicalRankResult {
4466 files: ranked,
4467 engine_capped,
4468 }
4469}
4470
4471#[cfg(test)]
4472fn lexical_score_snapshot_reference(
4473 index: &SearchIndexSnapshot,
4474 query_trigrams: &[u32],
4475 file_id: u32,
4476) -> f32 {
4477 if query_trigrams.is_empty() {
4478 return 0.0;
4479 }
4480
4481 let mut hits = 0u32;
4482 for &trigram in query_trigrams {
4483 let postings = index.postings_for_trigram(trigram, None);
4484 if postings.binary_search(&file_id).is_ok() {
4485 hits += 1;
4486 }
4487 }
4488
4489 if hits == 0 {
4490 return 0.0;
4491 }
4492
4493 let file_trigram_count = index
4494 .file_trigram_count
4495 .get(file_id as usize)
4496 .copied()
4497 .unwrap_or(1)
4498 .max(1) as f32;
4499 (hits as f32) / (1.0 + file_trigram_count.ln())
4500}
4501
4502pub fn resolve_cache_dir(project_root: &Path, storage_dir: Option<&Path>) -> PathBuf {
4503 resolve_cache_dir_with_key(&artifact_cache_key(project_root), storage_dir)
4504}
4505
4506pub(crate) fn build_path_filters(
4507 include: &[String],
4508 exclude: &[String],
4509) -> Result<PathFilters, String> {
4510 Ok(PathFilters {
4511 includes: build_globset(include)?,
4512 excludes: build_globset(exclude)?,
4513 })
4514}
4515
4516pub(crate) fn walk_project_files(root: &Path, filters: &PathFilters) -> Vec<PathBuf> {
4517 walk_project_files_from(root, root, filters)
4518}
4519
4520pub fn walk_project_files_bounded_default(
4521 root: &Path,
4522 max_files: usize,
4523) -> Result<Vec<PathBuf>, usize> {
4524 walk_project_files_from_inner(root, root, &PathFilters::default(), Some(max_files), true)
4525}
4526
4527pub(crate) fn walk_project_files_bounded_matching<F>(
4528 root: &Path,
4529 filters: &PathFilters,
4530 max_files: usize,
4531 matches_file: F,
4532) -> Result<Vec<PathBuf>, usize>
4533where
4534 F: Fn(&Path) -> bool,
4535{
4536 walk_project_files_from_inner_matching(root, root, filters, Some(max_files), matches_file, true)
4537}
4538
4539pub fn walk_project_files_bounded_default_matching<F>(
4540 root: &Path,
4541 max_files: usize,
4542 matches_file: F,
4543) -> Result<Vec<PathBuf>, usize>
4544where
4545 F: Fn(&Path) -> bool,
4546{
4547 walk_project_files_from_inner_matching(
4548 root,
4549 root,
4550 &PathFilters::default(),
4551 Some(max_files),
4552 matches_file,
4553 true,
4554 )
4555}
4556
4557pub(crate) fn walk_project_files_from(
4558 filter_root: &Path,
4559 search_root: &Path,
4560 filters: &PathFilters,
4561) -> Vec<PathBuf> {
4562 walk_project_files_from_inner(filter_root, search_root, filters, None, true)
4563 .expect("unbounded project walk cannot exceed a file limit")
4564}
4565
4566pub(crate) fn has_any_project_file_from(
4567 filter_root: &Path,
4568 search_root: &Path,
4569 filters: &PathFilters,
4570) -> bool {
4571 walk_project_files_from_inner(filter_root, search_root, filters, Some(0), true).is_err()
4572}
4573
4574fn walk_project_files_from_inner(
4575 filter_root: &Path,
4576 search_root: &Path,
4577 filters: &PathFilters,
4578 max_files: Option<usize>,
4579 sort_by_mtime: bool,
4580) -> Result<Vec<PathBuf>, usize> {
4581 walk_project_files_from_inner_matching(
4582 filter_root,
4583 search_root,
4584 filters,
4585 max_files,
4586 |_| true,
4587 sort_by_mtime,
4588 )
4589}
4590
4591fn project_walk_builder(search_root: &Path) -> WalkBuilder {
4592 let mut builder = WalkBuilder::new(search_root);
4593 builder
4596 .same_file_system(true)
4597 .hidden(false)
4598 .git_ignore(true)
4599 .git_global(true)
4600 .git_exclude(true)
4601 .add_custom_ignore_filename(".aftignore")
4602 .filter_entry(|entry| {
4603 let name = entry.file_name().to_string_lossy();
4604 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4605 return !matches!(
4606 name.as_ref(),
4607 "node_modules"
4608 | "target"
4609 | "venv"
4610 | ".venv"
4611 | ".git"
4612 | "__pycache__"
4613 | ".tox"
4614 | "dist"
4615 | "build"
4616 );
4617 }
4618 true
4619 });
4620 builder
4621}
4622
4623fn walk_project_files_from_inner_matching<F>(
4624 filter_root: &Path,
4625 search_root: &Path,
4626 filters: &PathFilters,
4627 max_files: Option<usize>,
4628 matches_file: F,
4629 sort_by_mtime: bool,
4630) -> Result<Vec<PathBuf>, usize>
4631where
4632 F: Fn(&Path) -> bool,
4633{
4634 let builder = project_walk_builder(search_root);
4635
4636 let mut files = Vec::new();
4637 for entry in builder.build().filter_map(|entry| entry.ok()) {
4638 if !entry
4639 .file_type()
4640 .map_or(false, |file_type| file_type.is_file())
4641 {
4642 continue;
4643 }
4644 let path = entry.into_path();
4645 if filters.matches(filter_root, &path) && matches_file(&path) {
4646 files.push(path);
4647 if max_files.is_some_and(|limit| files.len() > limit) {
4648 return Err(files.len());
4649 }
4650 }
4651 }
4652
4653 if sort_by_mtime {
4654 sort_paths_by_mtime_desc(&mut files, filter_root);
4655 }
4656 Ok(files)
4657}
4658
4659pub(crate) fn read_searchable_text(path: &Path) -> Option<String> {
4660 let bytes = fs::read(path).ok()?;
4661 if is_binary_bytes(&bytes) {
4662 return None;
4663 }
4664 String::from_utf8(bytes).ok()
4665}
4666
4667fn read_indexed_file_bytes(path: &Path) -> Option<Vec<u8>> {
4668 fs::read(path).ok()
4669}
4670
4671pub(crate) fn relative_to_root(root: &Path, path: &Path) -> PathBuf {
4672 path.strip_prefix(root)
4673 .map(PathBuf::from)
4674 .unwrap_or_else(|_| path.to_path_buf())
4675}
4676
4677pub(crate) fn cache_relative_path(root: &Path, path: &Path) -> Option<PathBuf> {
4678 let normalized_root = normalize_path(root);
4679 let normalized_path = normalize_path(path);
4680 let relative = normalized_path.strip_prefix(&normalized_root).ok()?;
4681 validate_cached_relative_path(relative)
4682}
4683
4684#[derive(Default)]
4688struct ParentCanonicalizationMemo {
4689 canonical_parents: HashMap<PathBuf, Option<PathBuf>>,
4690}
4691
4692impl ParentCanonicalizationMemo {
4693 fn canonicalize_parent_with<F>(
4694 &mut self,
4695 parent: &Path,
4696 canonicalize: &mut F,
4697 ) -> Option<PathBuf>
4698 where
4699 F: FnMut(&Path) -> std::io::Result<PathBuf>,
4700 {
4701 self.canonical_parents
4702 .entry(parent.to_path_buf())
4703 .or_insert_with(|| canonicalize(parent).ok())
4704 .clone()
4705 }
4706}
4707
4708pub(crate) fn cached_path_under_root(root: &Path, relative_path: &Path) -> Option<PathBuf> {
4709 let mut canonical_parents = ParentCanonicalizationMemo::default();
4710 cached_path_under_root_with_memo(root, relative_path, &mut canonical_parents)
4711}
4712
4713fn cached_path_under_root_with_memo(
4714 root: &Path,
4715 relative_path: &Path,
4716 canonical_parents: &mut ParentCanonicalizationMemo,
4717) -> Option<PathBuf> {
4718 let mut canonicalize = |path: &Path| fs::canonicalize(path);
4719 cached_path_under_root_with_memo_and_canonicalizer(
4720 root,
4721 relative_path,
4722 canonical_parents,
4723 &mut canonicalize,
4724 )
4725}
4726
4727fn cached_path_under_root_with_memo_and_canonicalizer<F>(
4728 root: &Path,
4729 relative_path: &Path,
4730 canonical_parents: &mut ParentCanonicalizationMemo,
4731 canonicalize: &mut F,
4732) -> Option<PathBuf>
4733where
4734 F: FnMut(&Path) -> std::io::Result<PathBuf>,
4735{
4736 let relative = validate_cached_relative_path(relative_path)?;
4737 let normalized_root = normalize_path(root);
4738 let full_path = normalize_path(&normalized_root.join(relative));
4739
4740 match fs::symlink_metadata(&full_path) {
4741 Ok(metadata) if !metadata.file_type().is_symlink() => {
4742 let parent = full_path.parent()?;
4743 let file_name = full_path.file_name()?;
4744 let canonical_path = canonical_parents
4745 .canonicalize_parent_with(parent, canonicalize)?
4746 .join(file_name);
4747 cached_path_if_contained(
4748 &normalized_root,
4749 &full_path,
4750 &canonical_path,
4751 canonical_parents,
4752 canonicalize,
4753 )
4754 }
4755 Ok(_) => {
4756 let canonical_path = canonicalize(&full_path).ok()?;
4757 cached_path_if_contained(
4758 &normalized_root,
4759 &full_path,
4760 &canonical_path,
4761 canonical_parents,
4762 canonicalize,
4763 )
4764 }
4765 Err(_) => {
4766 if let Some(parent) = full_path.parent() {
4771 let _ = canonical_parents.canonicalize_parent_with(parent, canonicalize);
4772 }
4773 is_within_search_root(&normalized_root, &full_path).then_some(full_path)
4774 }
4775 }
4776}
4777
4778fn cached_path_if_contained<F>(
4779 normalized_root: &Path,
4780 full_path: &Path,
4781 canonical_path: &Path,
4782 canonical_parents: &mut ParentCanonicalizationMemo,
4783 canonicalize: &mut F,
4784) -> Option<PathBuf>
4785where
4786 F: FnMut(&Path) -> std::io::Result<PathBuf>,
4787{
4788 if is_within_search_root(normalized_root, canonical_path) {
4793 return Some(full_path.to_path_buf());
4794 }
4795
4796 let canonical_root =
4797 canonical_parents.canonicalize_parent_with(normalized_root, canonicalize)?;
4798 is_within_search_root(&canonical_root, canonical_path).then_some(full_path.to_path_buf())
4799}
4800
4801pub(crate) fn validate_cached_relative_path(path: &Path) -> Option<PathBuf> {
4802 if path.is_absolute() {
4803 return None;
4804 }
4805
4806 let mut normalized = PathBuf::new();
4807 for component in path.components() {
4808 match component {
4809 Component::Normal(part) => normalized.push(part),
4810 Component::CurDir => {}
4811 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
4812 }
4813 }
4814 (!normalized.as_os_str().is_empty()).then_some(normalized)
4815}
4816
4817pub(crate) fn sort_paths_by_mtime_desc(paths: &mut [PathBuf], stable_root: &Path) {
4826 sort_paths_by_mtime_desc_with_key_normalization(paths, stable_root, true);
4827}
4828
4829pub(crate) fn sort_walked_paths_by_mtime_desc(paths: &mut [PathBuf], stable_root: &Path) {
4836 sort_paths_by_mtime_desc_with_key_normalization(paths, stable_root, false);
4837}
4838
4839fn sort_paths_by_mtime_desc_with_key_normalization(
4840 paths: &mut [PathBuf],
4841 stable_root: &Path,
4842 canonicalize_paths: bool,
4843) {
4844 use std::collections::HashMap;
4845 let stable_root = crate::inspect::job::canonicalize_normalized(stable_root);
4846 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::with_capacity(paths.len());
4847 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(paths.len());
4848 for path in paths.iter() {
4849 mtimes
4850 .entry(path.clone())
4851 .or_insert_with(|| path_modified_time(path));
4852 display_paths.entry(path.clone()).or_insert_with(|| {
4853 let resolved = if path.is_absolute() {
4854 path.clone()
4855 } else {
4856 stable_root.join(path)
4857 };
4858 let comparison_path = if canonicalize_paths {
4859 crate::inspect::job::canonicalize_normalized(&resolved)
4860 } else {
4861 crate::inspect::job::normalize_path(&resolved)
4862 };
4863 normalized_display_sort_key(Some(&stable_root), &comparison_path)
4864 });
4865 }
4866 paths.sort_by(|left, right| {
4867 let left_mtime = mtimes.get(left).and_then(|v| *v);
4868 let right_mtime = mtimes.get(right).and_then(|v| *v);
4869 let left_display = display_paths
4870 .get(left)
4871 .map(String::as_bytes)
4872 .unwrap_or_default();
4873 let right_display = display_paths
4874 .get(right)
4875 .map(String::as_bytes)
4876 .unwrap_or_default();
4877 right_mtime
4878 .cmp(&left_mtime)
4879 .then_with(|| left_display.cmp(right_display))
4880 .then_with(|| left.cmp(right))
4881 });
4882}
4883
4884pub(crate) fn sort_grep_matches_by_mtime_desc(matches: &mut [GrepMatch], project_root: &Path) {
4887 use std::collections::HashMap;
4888 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::new();
4889 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(matches.len());
4890 for m in matches.iter() {
4891 mtimes.entry(m.file.clone()).or_insert_with(|| {
4892 let resolved = resolve_match_path(project_root, &m.file);
4893 path_modified_time(&resolved)
4894 });
4895 display_paths
4896 .entry(m.file.clone())
4897 .or_insert_with(|| normalized_display_sort_key(Some(project_root), &m.file));
4898 }
4899 matches.sort_by(|left, right| {
4900 let left_mtime = mtimes.get(&left.file).and_then(|v| *v);
4901 let right_mtime = mtimes.get(&right.file).and_then(|v| *v);
4902 let left_display = display_paths
4903 .get(&left.file)
4904 .map(String::as_bytes)
4905 .unwrap_or_default();
4906 let right_display = display_paths
4907 .get(&right.file)
4908 .map(String::as_bytes)
4909 .unwrap_or_default();
4910 right_mtime
4914 .cmp(&left_mtime)
4915 .then_with(|| left_display.cmp(right_display))
4916 .then_with(|| left.line.cmp(&right.line))
4917 .then_with(|| left.column.cmp(&right.column))
4918 });
4919}
4920
4921fn sort_shared_grep_matches_by_cached_mtime_desc<F>(
4926 matches: &mut [SharedGrepMatch],
4927 project_root: &Path,
4928 modified_for_path: F,
4929) where
4930 F: Fn(&Path) -> Option<SystemTime>,
4931{
4932 use std::collections::HashMap;
4933 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::with_capacity(matches.len());
4934 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(matches.len());
4935 for m in matches.iter() {
4936 let path = m.file.as_path().to_path_buf();
4937 mtimes
4938 .entry(path.clone())
4939 .or_insert_with(|| modified_for_path(&path));
4940 display_paths
4941 .entry(path.clone())
4942 .or_insert_with(|| normalized_display_sort_key(Some(project_root), &path));
4943 }
4944 matches.sort_by(|left, right| {
4945 let left_mtime = mtimes.get(left.file.as_path()).and_then(|v| *v);
4946 let right_mtime = mtimes.get(right.file.as_path()).and_then(|v| *v);
4947 let left_display = display_paths
4948 .get(left.file.as_path())
4949 .map(String::as_bytes)
4950 .unwrap_or_default();
4951 let right_display = display_paths
4952 .get(right.file.as_path())
4953 .map(String::as_bytes)
4954 .unwrap_or_default();
4955 right_mtime
4959 .cmp(&left_mtime)
4960 .then_with(|| left_display.cmp(right_display))
4961 .then_with(|| left.line.cmp(&right.line))
4962 .then_with(|| left.column.cmp(&right.column))
4963 });
4964}
4965
4966pub(crate) fn resolve_search_scope(project_root: &Path, path: Option<&str>) -> SearchScope {
4967 let resolved_project_root = canonicalize_or_normalize(project_root);
4971 let root = match path {
4972 Some(path) => {
4973 let path = PathBuf::from(path);
4974 if path.is_absolute() {
4975 canonicalize_or_normalize(&path)
4976 } else {
4977 normalize_path(&resolved_project_root.join(path))
4978 }
4979 }
4980 None => resolved_project_root.clone(),
4981 };
4982
4983 let use_index = is_within_search_root(&resolved_project_root, &root);
4984 SearchScope { root, use_index }
4985}
4986
4987pub(crate) fn is_binary_bytes(content: &[u8]) -> bool {
4988 content_inspector::inspect(content).is_binary()
4989}
4990
4991pub(crate) fn current_git_head(root: &Path) -> Option<String> {
4992 run_git(root, &["rev-parse", "HEAD"])
4993}
4994
4995#[derive(Clone, Debug, PartialEq, Eq)]
4996pub struct ArtifactCacheKeyProbeError {
4997 root: PathBuf,
4998 detail: String,
4999}
5000
5001impl ArtifactCacheKeyProbeError {
5002 pub fn root(&self) -> &Path {
5003 &self.root
5004 }
5005
5006 pub fn detail(&self) -> &str {
5007 &self.detail
5008 }
5009}
5010
5011impl std::fmt::Display for ArtifactCacheKeyProbeError {
5012 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5013 write!(
5014 formatter,
5015 "artifact cache key probe failed for {}: {}",
5016 self.root.display(),
5017 self.detail
5018 )
5019 }
5020}
5021
5022impl std::error::Error for ArtifactCacheKeyProbeError {}
5023
5024pub fn artifact_cache_key(project_root: &Path) -> String {
5025 let key = match repo_root_commit_with_retry(project_root) {
5026 RootCommitResolution::Commit(root_commit) => artifact_key_from_git_identity(&root_commit),
5027 RootCommitResolution::NotARepo => artifact_path_identity_key(project_root),
5028 RootCommitResolution::Failed(detail) => {
5029 crate::slog_warn!(
5030 "artifact cache key: git root-commit probe failed after retries ({}); \
5031 falling back to path identity for {}",
5032 detail,
5033 project_root.display()
5034 );
5035 artifact_path_identity_key(project_root)
5036 }
5037 RootCommitResolution::Cancelled => artifact_path_identity_key(project_root),
5038 };
5039 record_derived_cache_key(project_root, &key);
5040 key
5041}
5042
5043pub fn artifact_cache_key_with_memo(
5044 probe_root: &Path,
5045 memo_root: &Path,
5046 storage_root: &Path,
5047 git_common_dir: Option<&Path>,
5048) -> Result<String, ArtifactCacheKeyProbeError> {
5049 let memo_root_key = artifact_cache_key_memo_root_key(memo_root);
5050 let git_marker_state = root_git_marker_state(probe_root, git_common_dir);
5051 if git_marker_state == GitMarkerState::Absent {
5052 return Ok(artifact_path_identity_key(probe_root));
5053 }
5054
5055 match repo_root_commit_with_retry(probe_root) {
5056 RootCommitResolution::Commit(root_commit) => {
5057 let key = artifact_key_from_git_identity(&root_commit);
5058 record_derived_cache_key(memo_root, &key);
5059 if let Err(error) =
5060 record_artifact_cache_key_memo(storage_root, &memo_root_key, &key, &root_commit)
5061 {
5062 crate::slog_warn!(
5063 "artifact cache key: failed to persist memo for {} in {}: {}",
5064 memo_root.display(),
5065 storage_root.display(),
5066 error
5067 );
5068 }
5069 Ok(key)
5070 }
5071 RootCommitResolution::NotARepo => Ok(artifact_path_identity_key(probe_root)),
5072 RootCommitResolution::Failed(detail) => {
5073 if let Some(entry) = lookup_artifact_cache_key_memo(storage_root, &memo_root_key) {
5074 crate::slog_warn!(
5075 "artifact cache key: probe failed, using memoized key {} for {}",
5076 entry.key,
5077 memo_root.display()
5078 );
5079 return Ok(entry.key);
5080 }
5081
5082 match git_marker_state {
5083 GitMarkerState::Absent => Ok(artifact_path_identity_key(probe_root)),
5084 GitMarkerState::Present => Err(ArtifactCacheKeyProbeError {
5085 root: memo_root.to_path_buf(),
5086 detail,
5087 }),
5088 GitMarkerState::Unknown(marker_detail) => Err(ArtifactCacheKeyProbeError {
5089 root: memo_root.to_path_buf(),
5090 detail: format!("{detail}; {marker_detail}"),
5091 }),
5092 }
5093 }
5094 RootCommitResolution::Cancelled => Err(ArtifactCacheKeyProbeError {
5095 root: memo_root.to_path_buf(),
5096 detail: "artifact cache key probe cancelled".to_string(),
5097 }),
5098 }
5099}
5100
5101static DERIVED_CACHE_KEYS: OnceLock<RwLock<HashMap<PathBuf, String>>> = OnceLock::new();
5106
5107fn record_derived_cache_key(project_root: &Path, key: &str) {
5108 let map = DERIVED_CACHE_KEYS.get_or_init(|| RwLock::new(HashMap::new()));
5109 if let Ok(mut map) = map.write() {
5110 map.insert(project_root.to_path_buf(), key.to_string());
5111 }
5112}
5113
5114pub fn artifact_cache_key_memoized_only(project_root: &Path) -> Option<String> {
5127 let map = DERIVED_CACHE_KEYS.get()?;
5128 let map = map.try_read().ok()?;
5129 map.get(project_root).cloned()
5130}
5131
5132pub fn resolve_cache_dir_with_key(project_key: &str, storage_dir: Option<&Path>) -> PathBuf {
5133 crate::bash_background::storage_dir(storage_dir)
5134 .join("index")
5135 .join(project_key)
5136}
5137
5138pub(crate) fn artifact_key_from_git_identity(root_commit: &str) -> String {
5140 artifact_hash16(root_commit.as_bytes())
5141}
5142
5143pub(crate) fn artifact_path_identity_key(project_root: &Path) -> String {
5146 let canonical_root = canonicalize_or_normalize(project_root);
5147 artifact_hash16(canonical_root.to_string_lossy().as_bytes())
5148}
5149
5150#[cfg(test)]
5151pub(crate) fn artifact_path_identity_key_for_test(project_root: &Path) -> String {
5152 artifact_path_identity_key(project_root)
5153}
5154
5155fn artifact_hash16(bytes: &[u8]) -> String {
5156 use sha2::{Digest, Sha256};
5157
5158 let mut hasher = Sha256::new();
5159 hasher.update(bytes);
5160 let digest = format!("{:x}", hasher.finalize());
5161 digest[..16].to_string()
5162}
5163
5164fn artifact_cache_key_memo_root_key(root: &Path) -> String {
5165 root.to_string_lossy().into_owned()
5166}
5167
5168fn artifact_cache_key_memo_path(storage_root: &Path) -> PathBuf {
5169 storage_root.join(ARTIFACT_CACHE_KEY_MEMO_FILE)
5170}
5171
5172fn artifact_cache_key_memo_state() -> &'static Mutex<ArtifactCacheKeyMemoState> {
5173 ARTIFACT_CACHE_KEY_MEMO_STATE.get_or_init(|| Mutex::new(ArtifactCacheKeyMemoState::default()))
5174}
5175
5176impl ArtifactCacheKeyMemoState {
5177 fn entries_for_storage_root(
5178 &mut self,
5179 storage_root: &Path,
5180 ) -> &mut BTreeMap<String, ArtifactCacheKeyMemoEntry> {
5181 if !self.by_storage_root.contains_key(storage_root) {
5182 let entries = read_artifact_cache_key_memo_file(storage_root);
5183 self.by_storage_root
5184 .insert(storage_root.to_path_buf(), entries);
5185 }
5186 self.by_storage_root
5187 .get_mut(storage_root)
5188 .expect("memo storage root inserted")
5189 }
5190}
5191
5192fn lookup_artifact_cache_key_memo(
5193 storage_root: &Path,
5194 memo_root_key: &str,
5195) -> Option<ArtifactCacheKeyMemoEntry> {
5196 let mut state = artifact_cache_key_memo_state()
5197 .lock()
5198 .unwrap_or_else(std::sync::PoisonError::into_inner);
5199 let entries = state.entries_for_storage_root(storage_root);
5200 let entry = entries.get(memo_root_key)?.clone();
5201 let now = current_time_millis();
5202 if Path::new(memo_root_key).exists()
5203 && now.saturating_sub(entry.recorded_at_ms)
5204 >= ARTIFACT_CACHE_KEY_MEMO_READ_REFRESH_AGE.as_millis() as u64
5205 {
5206 let mut refreshed = entry.clone();
5210 refreshed.recorded_at_ms = now;
5211 entries.insert(memo_root_key.to_string(), refreshed);
5212 if let Err(error) = write_artifact_cache_key_memo_file(storage_root, entries) {
5213 entries.insert(memo_root_key.to_string(), entry.clone());
5214 crate::slog_warn!(
5215 "artifact cache key: failed to refresh memo for {} in {}: {}",
5216 memo_root_key,
5217 storage_root.display(),
5218 error
5219 );
5220 }
5221 }
5222 entries.get(memo_root_key).cloned()
5223}
5224
5225fn record_artifact_cache_key_memo(
5226 storage_root: &Path,
5227 memo_root_key: &str,
5228 key: &str,
5229 git_root_commit: &str,
5230) -> std::io::Result<()> {
5231 let mut state = artifact_cache_key_memo_state()
5232 .lock()
5233 .unwrap_or_else(std::sync::PoisonError::into_inner);
5234 let entries = state.entries_for_storage_root(storage_root);
5235 if entries
5236 .get(memo_root_key)
5237 .is_some_and(|entry| entry.key == key && entry.git_root_commit == git_root_commit)
5238 {
5239 return Ok(());
5240 }
5241 let now = current_time_millis();
5242 entries.insert(
5243 memo_root_key.to_string(),
5244 ArtifactCacheKeyMemoEntry {
5245 key: key.to_string(),
5246 git_root_commit: git_root_commit.to_string(),
5247 recorded_at_ms: now,
5248 },
5249 );
5250 prune_expired_artifact_cache_key_memo_entries(entries, now);
5251 write_artifact_cache_key_memo_file(storage_root, entries)
5252}
5253
5254fn prune_expired_artifact_cache_key_memo_entries(
5255 entries: &mut BTreeMap<String, ArtifactCacheKeyMemoEntry>,
5256 now: u64,
5257) {
5258 entries.retain(|root, entry| {
5259 Path::new(root).exists()
5260 || now.saturating_sub(entry.recorded_at_ms)
5261 <= ARTIFACT_CACHE_KEY_MEMO_EVICTION_AGE.as_millis() as u64
5262 });
5263}
5264
5265fn read_artifact_cache_key_memo_file(
5266 storage_root: &Path,
5267) -> BTreeMap<String, ArtifactCacheKeyMemoEntry> {
5268 let path = artifact_cache_key_memo_path(storage_root);
5269 let bytes = match fs::read(&path) {
5270 Ok(bytes) => bytes,
5271 Err(_) => return BTreeMap::new(),
5272 };
5273 let entries =
5274 match serde_json::from_slice::<BTreeMap<String, ArtifactCacheKeyMemoEntry>>(&bytes) {
5275 Ok(entries) => entries,
5276 Err(error) => {
5277 crate::slog_warn!(
5278 "artifact cache key: ignoring corrupt memo file {}: {}",
5279 path.display(),
5280 error
5281 );
5282 return BTreeMap::new();
5283 }
5284 };
5285 entries
5286 .into_iter()
5287 .filter(|(root, entry)| {
5288 !root.is_empty()
5289 && artifact_key_looks_valid(&entry.key)
5290 && !entry.git_root_commit.trim().is_empty()
5291 })
5292 .collect()
5293}
5294
5295fn write_artifact_cache_key_memo_file(
5296 storage_root: &Path,
5297 entries: &BTreeMap<String, ArtifactCacheKeyMemoEntry>,
5298) -> std::io::Result<()> {
5299 fs::create_dir_all(storage_root)?;
5300 let path = artifact_cache_key_memo_path(storage_root);
5301 let temp_path = storage_root.join(format!(
5302 ".{ARTIFACT_CACHE_KEY_MEMO_FILE}.tmp.{}.{}",
5303 std::process::id(),
5304 SystemTime::now()
5305 .duration_since(UNIX_EPOCH)
5306 .unwrap_or(Duration::ZERO)
5307 .as_nanos()
5308 ));
5309 let bytes = serde_json::to_vec_pretty(entries).map_err(std::io::Error::other)?;
5310 {
5311 let mut file = File::create(&temp_path)?;
5312 file.write_all(&bytes)?;
5313 }
5314 if let Err(error) = fs::rename(&temp_path, &path) {
5315 let _ = fs::remove_file(&temp_path);
5316 return Err(error);
5317 }
5318 Ok(())
5319}
5320
5321fn artifact_key_looks_valid(key: &str) -> bool {
5322 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
5323}
5324
5325pub(crate) fn sweep_orphaned_index_dirs(storage_root: &Path) {
5329 let index_root = storage_root.join("index");
5330 let referenced_keys = match referenced_artifact_cache_keys(storage_root) {
5331 Ok(keys) => keys,
5332 Err(error) => {
5333 crate::slog_warn!(
5334 "search index orphan sweep root={} scanned=0 removed=0 skipped_derived=0 skipped_memo=0 skipped_fresh=0 skipped_live=0 skipped_locked=0 skipped_unreadable=0 budget_exhausted=false memo_unreadable=true error={}",
5335 index_root.display(),
5336 error
5337 );
5338 return;
5339 }
5340 };
5341 let derived_keys = derived_artifact_cache_keys();
5342 let mut summary = IndexOrphanSweepSummary::default();
5343 let mut entries = match fs::read_dir(&index_root) {
5344 Ok(entries) => entries
5345 .filter_map(Result::ok)
5346 .filter_map(|entry| {
5347 let file_type = entry.file_type().ok()?;
5348 let name = entry.file_name().to_string_lossy().into_owned();
5349 (file_type.is_dir() && artifact_key_looks_valid(&name))
5350 .then(|| (name, entry.path()))
5351 })
5352 .collect::<Vec<_>>(),
5353 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
5354 Err(_) => {
5355 crate::slog_warn!(
5356 "search index orphan sweep root={} scanned=0 removed=0 skipped_derived=0 skipped_memo=0 skipped_fresh=0 skipped_live=0 skipped_locked=0 skipped_unreadable=1 budget_exhausted=false memo_unreadable=false",
5357 index_root.display()
5358 );
5359 return;
5360 }
5361 };
5362 entries.sort_by(|left, right| left.0.cmp(&right.0));
5363
5364 let cursor_store = INDEX_ORPHAN_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
5365 let last_name = cursor_store
5366 .lock()
5367 .ok()
5368 .and_then(|cursors| cursors.get(&index_root).cloned());
5369 if let Some(start) = last_name
5370 .as_deref()
5371 .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
5372 {
5373 entries.rotate_left(start);
5374 }
5375
5376 let mut cursor_name = last_name;
5377 for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
5378 if processed >= INDEX_ORPHAN_SWEEP_LIMIT {
5379 summary.budget_exhausted = true;
5380 break;
5381 }
5382 summary.scanned += 1;
5383 cursor_name = Some(key.clone());
5384 if derived_keys.contains(&key) {
5385 summary.skipped_derived += 1;
5386 continue;
5387 }
5388 if referenced_keys.contains(&key) {
5389 summary.skipped_memo += 1;
5390 continue;
5391 }
5392 let newest_file = match newest_index_cache_file_mtime(&cache_dir) {
5393 Ok(Some(time)) => time,
5394 Ok(None) | Err(()) => {
5395 summary.skipped_unreadable += 1;
5396 continue;
5397 }
5398 };
5399 if SystemTime::now()
5400 .duration_since(newest_file)
5401 .unwrap_or(Duration::ZERO)
5402 <= INDEX_ORPHAN_MIN_AGE
5403 {
5404 summary.skipped_fresh += 1;
5405 continue;
5406 }
5407
5408 let Some((_cache_lock, _writer_lease)) = try_acquire_index_orphan_sweep_locks(&cache_dir)
5409 else {
5410 summary.skipped_locked += 1;
5411 continue;
5412 };
5413 if crate::root_cache::sweep_all_read_markers(&cache_dir).protected {
5414 summary.skipped_live += 1;
5415 continue;
5416 }
5417 match fs::remove_dir_all(&cache_dir) {
5418 Ok(()) => summary.removed += 1,
5419 Err(error) if error.kind() == std::io::ErrorKind::NotFound => summary.removed += 1,
5420 Err(_) => summary.skipped_unreadable += 1,
5421 }
5422 }
5423
5424 if let Ok(mut cursors) = cursor_store.lock() {
5425 if summary.budget_exhausted {
5426 if let Some(cursor_name) = cursor_name {
5427 cursors.insert(index_root.clone(), cursor_name);
5428 }
5429 } else {
5430 cursors.remove(&index_root);
5431 }
5432 }
5433 if summary.removed > 0 {
5434 crate::fs_lock::sync_parent(&index_root);
5435 }
5436 crate::slog_info!(
5437 "search index orphan sweep root={} scanned={} removed={} skipped_derived={} skipped_memo={} skipped_fresh={} skipped_live={} skipped_locked={} skipped_unreadable={} budget_exhausted={}",
5438 index_root.display(),
5439 summary.scanned,
5440 summary.removed,
5441 summary.skipped_derived,
5442 summary.skipped_memo,
5443 summary.skipped_fresh,
5444 summary.skipped_live,
5445 summary.skipped_locked,
5446 summary.skipped_unreadable,
5447 summary.budget_exhausted
5448 );
5449}
5450
5451pub(crate) fn referenced_artifact_cache_keys(
5455 storage_root: &Path,
5456) -> std::io::Result<HashSet<String>> {
5457 let state = artifact_cache_key_memo_state()
5458 .lock()
5459 .unwrap_or_else(std::sync::PoisonError::into_inner);
5460 let entries = state
5461 .by_storage_root
5462 .get(storage_root)
5463 .cloned()
5464 .map(Ok)
5465 .unwrap_or_else(|| {
5466 let path = artifact_cache_key_memo_path(storage_root);
5467 match fs::read(path) {
5468 Ok(bytes) => {
5469 serde_json::from_slice::<BTreeMap<String, ArtifactCacheKeyMemoEntry>>(&bytes)
5470 .map_err(std::io::Error::other)
5471 }
5472 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
5473 Err(error) => Err(error),
5474 }
5475 })?;
5476 Ok(entries
5477 .into_values()
5478 .filter_map(|entry| artifact_key_looks_valid(&entry.key).then_some(entry.key))
5479 .collect())
5480}
5481
5482pub(crate) fn derived_artifact_cache_keys() -> HashSet<String> {
5485 DERIVED_CACHE_KEYS
5486 .get()
5487 .and_then(|keys| keys.try_read().ok())
5488 .map(|keys| keys.values().cloned().collect())
5489 .unwrap_or_default()
5490}
5491
5492fn try_acquire_index_orphan_sweep_locks(
5493 cache_dir: &Path,
5494) -> Option<(fs_lock::LockGuard, fs_lock::LockGuard)> {
5495 let cache_lock = fs_lock::try_acquire(&cache_dir.join("cache.lock"), Duration::ZERO).ok()?;
5496 let writer_lease = fs_lock::try_acquire(
5497 &crate::root_cache::writer_lease_path(cache_dir),
5498 Duration::ZERO,
5499 )
5500 .ok()?;
5501 Some((cache_lock, writer_lease))
5502}
5503
5504fn newest_index_cache_file_mtime(cache_dir: &Path) -> Result<Option<SystemTime>, ()> {
5505 let boundary = crate::walk_boundary::DeviceBoundary::for_root(cache_dir).map_err(|_| ())?;
5506 let mut newest = None;
5507 newest_index_cache_file_mtime_inner(cache_dir, &boundary, &mut newest)?;
5508 Ok(newest)
5509}
5510
5511fn newest_index_cache_file_mtime_inner(
5512 directory: &Path,
5513 boundary: &crate::walk_boundary::DeviceBoundary,
5514 newest: &mut Option<SystemTime>,
5515) -> Result<(), ()> {
5516 for entry in fs::read_dir(directory).map_err(|_| ())? {
5517 let entry = entry.map_err(|_| ())?;
5518 let file_type = entry.file_type().map_err(|_| ())?;
5519 if file_type.is_symlink() {
5520 return Err(());
5521 }
5522 let path = entry.path();
5523 if file_type.is_dir() {
5524 if !boundary.should_descend(&path).map_err(|_| ())? {
5527 crate::slog_warn!(
5528 "search-index cache scan skipped foreign filesystem mount {}",
5529 path.display()
5530 );
5531 continue;
5532 }
5533 newest_index_cache_file_mtime_inner(&path, boundary, newest)?;
5534 } else if file_type.is_file() {
5535 let modified = entry
5536 .metadata()
5537 .map_err(|_| ())?
5538 .modified()
5539 .map_err(|_| ())?;
5540 if newest.is_none_or(|current| modified > current) {
5541 *newest = Some(modified);
5542 }
5543 } else {
5544 return Err(());
5545 }
5546 }
5547 Ok(())
5548}
5549
5550fn current_time_millis() -> u64 {
5551 SystemTime::now()
5552 .duration_since(UNIX_EPOCH)
5553 .unwrap_or(Duration::ZERO)
5554 .as_millis()
5555 .min(u128::from(u64::MAX)) as u64
5556}
5557
5558#[derive(Debug, PartialEq, Eq)]
5559enum GitMarkerState {
5560 Present,
5561 Absent,
5562 Unknown(String),
5563}
5564
5565fn root_git_marker_state(project_root: &Path, git_common_dir: Option<&Path>) -> GitMarkerState {
5566 if git_common_dir.is_some() {
5567 return GitMarkerState::Present;
5568 }
5569 let git_marker = project_root.join(".git");
5570 match fs::symlink_metadata(&git_marker) {
5571 Ok(_) => GitMarkerState::Present,
5572 Err(error) if error.kind() == std::io::ErrorKind::NotFound => GitMarkerState::Absent,
5573 Err(error) => GitMarkerState::Unknown(format!(
5574 "failed to inspect git marker {}: {}",
5575 git_marker.display(),
5576 error
5577 )),
5578 }
5579}
5580
5581pub(crate) fn git_root_probe_confirms_non_repo(project_root: &Path) -> bool {
5584 matches!(
5585 repo_root_commit_with_retry(project_root),
5586 RootCommitResolution::NotARepo
5587 )
5588}
5589
5590pub(crate) fn canonical_git_root_commit_identity(
5595 project_root: &Path,
5596) -> Result<String, ArtifactCacheKeyProbeError> {
5597 match repo_root_commit_with_retry(project_root) {
5598 RootCommitResolution::Commit(identity) => Ok(identity),
5599 RootCommitResolution::NotARepo => Err(ArtifactCacheKeyProbeError {
5600 root: project_root.to_path_buf(),
5601 detail: "not a git repository".to_string(),
5602 }),
5603 RootCommitResolution::Failed(detail) => Err(ArtifactCacheKeyProbeError {
5604 root: project_root.to_path_buf(),
5605 detail,
5606 }),
5607 RootCommitResolution::Cancelled => Err(ArtifactCacheKeyProbeError {
5608 root: project_root.to_path_buf(),
5609 detail: "artifact cache key probe cancelled".to_string(),
5610 }),
5611 }
5612}
5613
5614fn repo_root_commit_with_retry(project_root: &Path) -> RootCommitResolution {
5623 for attempt in 0..3u32 {
5624 if root_commit_probe_cancelled() {
5625 return RootCommitResolution::Cancelled;
5626 }
5627 let probe = git_root_commit_once(project_root);
5628 if root_commit_probe_cancelled() {
5629 return RootCommitResolution::Cancelled;
5630 }
5631 match probe {
5632 RootCommitProbe::Commit(commit) => return RootCommitResolution::Commit(commit),
5633 RootCommitProbe::NotARepo => return RootCommitResolution::NotARepo,
5634 RootCommitProbe::NoCommit => return RootCommitResolution::NotARepo,
5635 RootCommitProbe::Transient(detail) => {
5636 if attempt == 2 {
5637 return RootCommitResolution::Failed(detail);
5638 }
5639 if root_commit_probe_cancelled() {
5640 return RootCommitResolution::Cancelled;
5641 }
5642 std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1)));
5643 if root_commit_probe_cancelled() {
5644 return RootCommitResolution::Cancelled;
5645 }
5646 }
5647 }
5648 }
5649 RootCommitResolution::Failed("git root-commit probe retry loop exhausted".to_string())
5650}
5651
5652fn root_commit_probe_cancelled() -> bool {
5653 crate::executor::current_job_cancellation()
5654 .is_some_and(|token| token.cancel_requested_before_commit())
5655}
5656
5657enum RootCommitResolution {
5658 Commit(String),
5659 NotARepo,
5660 Failed(String),
5661 Cancelled,
5662}
5663
5664enum RootCommitProbe {
5665 Commit(String),
5666 NotARepo,
5668 NoCommit,
5670 Transient(String),
5672}
5673
5674fn git_root_commit_once(project_root: &Path) -> RootCommitProbe {
5675 #[cfg(test)]
5676 if let Some(override_probe) = GIT_ROOT_COMMIT_PROBE_OVERRIDE
5677 .get_or_init(|| Mutex::new(None))
5678 .lock()
5679 .unwrap_or_else(std::sync::PoisonError::into_inner)
5680 .clone()
5681 {
5682 if let Some(result) = override_probe(project_root) {
5683 return result;
5684 }
5685 }
5686
5687 git_root_commit_once_real(project_root)
5688}
5689
5690fn canonicalize_root_commit_output(stdout: &[u8]) -> RootCommitProbe {
5694 let decoded = String::from_utf8_lossy(stdout);
5695 let mut roots: Vec<&str> = decoded
5696 .lines()
5697 .map(str::trim)
5698 .filter(|line| !line.is_empty())
5699 .collect();
5700 roots.sort_unstable();
5701 roots.dedup();
5702
5703 if roots.is_empty() {
5704 RootCommitProbe::NoCommit
5705 } else {
5706 RootCommitProbe::Commit(roots.join("\n"))
5707 }
5708}
5709
5710fn git_root_commit_once_real(project_root: &Path) -> RootCommitProbe {
5711 let output = match crate::effective_path::new_command("git")
5712 .arg("-C")
5713 .arg(project_root)
5714 .args(["rev-list", "--max-parents=0", "HEAD"])
5715 .output()
5716 {
5717 Ok(output) => output,
5718 Err(error) => return RootCommitProbe::Transient(format!("spawn failed: {error}")),
5719 };
5720
5721 if output.status.success() {
5722 return canonicalize_root_commit_output(&output.stdout);
5723 }
5724
5725 let stderr = String::from_utf8_lossy(&output.stderr);
5726 if stderr.contains("not a git repository") {
5727 return RootCommitProbe::NotARepo;
5728 }
5729 if stderr.contains("unknown revision")
5730 || stderr.contains("bad revision")
5731 || stderr.contains("ambiguous argument 'HEAD'")
5732 {
5733 return RootCommitProbe::NoCommit;
5734 }
5735 RootCommitProbe::Transient(format!(
5736 "exit {:?}: {}",
5737 output.status.code(),
5738 stderr.trim().chars().take(200).collect::<String>()
5739 ))
5740}
5741
5742#[cfg(test)]
5743pub(crate) struct GitRootCommitProbeOverrideGuard {
5744 previous: Option<RootCommitProbeOverride>,
5745}
5746
5747#[cfg(test)]
5748impl Drop for GitRootCommitProbeOverrideGuard {
5749 fn drop(&mut self) {
5750 set_git_root_commit_probe_override_for_test(self.previous.take());
5751 }
5752}
5753
5754#[cfg(test)]
5755pub(crate) fn git_root_commit_probe_override_lock_for_test() -> std::sync::MutexGuard<'static, ()> {
5756 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5757 LOCK.get_or_init(|| Mutex::new(()))
5758 .lock()
5759 .unwrap_or_else(std::sync::PoisonError::into_inner)
5760}
5761
5762#[cfg(test)]
5763pub(crate) fn force_git_root_commit_probe_transient_for_paths_for_test(
5764 roots: Vec<PathBuf>,
5765 detail: impl Into<String>,
5766) -> GitRootCommitProbeOverrideGuard {
5767 let detail = Arc::new(detail.into());
5768 install_git_root_commit_probe_override_for_test(move |project_root| {
5769 roots
5770 .iter()
5771 .any(|root| root == project_root)
5772 .then(|| RootCommitProbe::Transient((*detail).clone()))
5773 })
5774}
5775
5776#[cfg(test)]
5777pub(crate) fn force_git_root_commit_probe_slow_transient_for_paths_for_test(
5778 roots: Vec<PathBuf>,
5779 delay: Duration,
5780 started: Arc<AtomicBool>,
5781) -> GitRootCommitProbeOverrideGuard {
5782 install_git_root_commit_probe_override_for_test(move |project_root| {
5783 roots.iter().any(|root| root == project_root).then(|| {
5784 started.store(true, Ordering::SeqCst);
5785 std::thread::sleep(delay);
5786 RootCommitProbe::Transient("stubbed slow git probe".to_string())
5787 })
5788 })
5789}
5790
5791#[cfg(test)]
5792pub(crate) fn force_git_root_commit_probe_commits_for_test(
5793 commits_by_root: BTreeMap<PathBuf, String>,
5794) -> GitRootCommitProbeOverrideGuard {
5795 install_git_root_commit_probe_override_for_test(move |project_root| {
5796 commits_by_root
5797 .get(project_root)
5798 .cloned()
5799 .map(RootCommitProbe::Commit)
5800 })
5801}
5802
5803#[cfg(test)]
5804fn install_git_root_commit_probe_override_for_test(
5805 override_probe: impl Fn(&Path) -> Option<RootCommitProbe> + Send + Sync + 'static,
5806) -> GitRootCommitProbeOverrideGuard {
5807 let previous = set_git_root_commit_probe_override_for_test(Some(Arc::new(override_probe)));
5808 GitRootCommitProbeOverrideGuard { previous }
5809}
5810
5811#[cfg(test)]
5812fn set_git_root_commit_probe_override_for_test(
5813 override_probe: Option<RootCommitProbeOverride>,
5814) -> Option<RootCommitProbeOverride> {
5815 let mut slot = GIT_ROOT_COMMIT_PROBE_OVERRIDE
5816 .get_or_init(|| Mutex::new(None))
5817 .lock()
5818 .unwrap_or_else(std::sync::PoisonError::into_inner);
5819 std::mem::replace(&mut *slot, override_probe)
5820}
5821
5822pub fn ignore_rules_fingerprint(project_root: &Path) -> String {
5830 use sha2::{Digest, Sha256};
5831
5832 let root = canonicalize_or_normalize(project_root);
5833 let mut files = Vec::new();
5834 collect_ignore_rule_files(&root, &mut files);
5835 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
5836 if global_ignore.is_file() {
5837 files.push(global_ignore);
5838 }
5839 }
5840 let info_exclude = git_info_exclude_path(&root);
5841 if info_exclude.is_file() {
5842 files.push(info_exclude);
5843 }
5844 files.sort();
5845 files.dedup();
5846
5847 let mut hasher = Sha256::new();
5848 hasher.update(b"aft-ignore-rules-v1\0");
5849 for path in files {
5850 if let Some(relative) = cache_relative_path(&root, &path) {
5851 hasher.update(relative.to_string_lossy().as_bytes());
5852 } else {
5853 hasher.update(path.to_string_lossy().as_bytes());
5854 }
5855 hasher.update(b"\0");
5856 match fs::read(&path) {
5857 Ok(bytes) => hasher.update(&bytes),
5858 Err(error) => hasher.update(format!("read-error:{error}").as_bytes()),
5859 }
5860 hasher.update(b"\0");
5861 }
5862
5863 format!("{:x}", hasher.finalize())
5864}
5865
5866fn git_info_exclude_path(root: &Path) -> PathBuf {
5867 run_git(
5868 root,
5869 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
5870 )
5871 .map(PathBuf::from)
5872 .unwrap_or_else(|| root.join(".git"))
5873 .join("info")
5874 .join("exclude")
5875}
5876
5877fn collect_ignore_rule_files(root: &Path, files: &mut Vec<PathBuf>) {
5878 let mut builder = WalkBuilder::new(root);
5879 builder
5882 .same_file_system(true)
5883 .hidden(false)
5884 .git_ignore(true)
5885 .git_global(true)
5886 .git_exclude(true)
5887 .add_custom_ignore_filename(".aftignore")
5888 .filter_entry(|entry| {
5889 let name = entry.file_name().to_string_lossy();
5890 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
5891 return !matches!(
5892 name.as_ref(),
5893 ".git"
5894 | "node_modules"
5895 | "target"
5896 | "venv"
5897 | ".venv"
5898 | "__pycache__"
5899 | ".tox"
5900 | "dist"
5901 | "build"
5902 );
5903 }
5904 true
5905 });
5906
5907 for entry in builder.build().filter_map(|entry| entry.ok()) {
5908 if !entry
5909 .file_type()
5910 .map_or(false, |file_type| file_type.is_file())
5911 {
5912 continue;
5913 }
5914 let file_name = entry.file_name();
5915 if file_name == ".gitignore" || file_name == ".aftignore" {
5916 files.push(entry.into_path());
5917 }
5918 }
5919}
5920
5921#[cfg(test)]
5923pub(crate) fn count_ignore_rule_discovery_dirs(root: &Path) -> usize {
5924 let mut dirs = 0usize;
5925 let mut builder = WalkBuilder::new(root);
5926 builder
5927 .same_file_system(true)
5928 .hidden(false)
5929 .git_ignore(true)
5930 .git_global(true)
5931 .git_exclude(true)
5932 .add_custom_ignore_filename(".aftignore");
5933 for entry in builder.build().filter_map(|entry| entry.ok()) {
5934 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
5935 dirs += 1;
5936 }
5937 }
5938 dirs
5939}
5940
5941#[cfg(test)]
5943pub(crate) fn count_ignore_rule_discovery_dirs_legacy_stack(root: &Path) -> usize {
5944 let mut stack = vec![root.to_path_buf()];
5945 let mut dirs = 0usize;
5946 while let Some(dir) = stack.pop() {
5947 dirs += 1;
5948 let Ok(entries) = fs::read_dir(&dir) else {
5949 continue;
5950 };
5951 for entry in entries.flatten() {
5952 let path = entry.path();
5953 let file_name = entry.file_name();
5954 if file_name == ".gitignore" || file_name == ".aftignore" {
5955 continue;
5956 }
5957 let Ok(file_type) = entry.file_type() else {
5958 continue;
5959 };
5960 if !file_type.is_dir() || file_type.is_symlink() {
5961 continue;
5962 }
5963 if matches!(
5964 file_name.to_str().unwrap_or(""),
5965 ".git"
5966 | "node_modules"
5967 | "target"
5968 | "venv"
5969 | ".venv"
5970 | "__pycache__"
5971 | ".tox"
5972 | "dist"
5973 | "build"
5974 ) {
5975 continue;
5976 }
5977 stack.push(path);
5978 }
5979 }
5980 dirs
5981}
5982
5983impl PathFilters {
5984 pub(crate) fn matches(&self, root: &Path, path: &Path) -> bool {
5985 let relative = to_glob_path(&relative_to_root(
5989 &normalize_path(root),
5990 &normalize_path(path),
5991 ));
5992 if self
5993 .includes
5994 .as_ref()
5995 .is_some_and(|includes| !includes.is_match(&relative))
5996 {
5997 return false;
5998 }
5999 if self
6000 .excludes
6001 .as_ref()
6002 .is_some_and(|excludes| excludes.is_match(&relative))
6003 {
6004 return false;
6005 }
6006 true
6007 }
6008}
6009
6010fn canonicalize_for_search_membership(path: &Path) -> PathBuf {
6011 crate::inspect::job::canonicalize_normalized(path)
6016}
6017
6018fn canonicalize_or_normalize(path: &Path) -> PathBuf {
6019 fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
6020}
6021
6022fn resolve_match_path(project_root: &Path, path: &Path) -> PathBuf {
6023 if path.is_absolute() {
6024 path.to_path_buf()
6025 } else {
6026 project_root.join(path)
6027 }
6028}
6029
6030fn path_modified_time(path: &Path) -> Option<SystemTime> {
6031 fs::metadata(path)
6032 .and_then(|metadata| metadata.modified())
6033 .ok()
6034}
6035
6036fn normalized_display_sort_key(project_root: Option<&Path>, path: &Path) -> String {
6037 let display_path = project_root
6038 .and_then(|root| path.strip_prefix(root).ok())
6039 .unwrap_or(path);
6040 to_glob_path(display_path)
6041}
6042
6043fn normalize_path(path: &Path) -> PathBuf {
6044 let mut result = PathBuf::new();
6045 for component in path.components() {
6046 match component {
6047 Component::ParentDir => {
6048 if !result.pop() {
6049 result.push(component);
6050 }
6051 }
6052 Component::CurDir => {}
6053 _ => result.push(component),
6054 }
6055 }
6056 result
6057}
6058
6059fn canonicalize_existing_or_deleted_path_with_memo(
6060 path: &Path,
6061 canonical_parents: &mut ParentCanonicalizationMemo,
6062) -> PathBuf {
6063 let mut canonicalize = |path: &Path| fs::canonicalize(path);
6064 canonicalize_existing_or_deleted_path_with_memo_and_canonicalizer(
6065 path,
6066 canonical_parents,
6067 &mut canonicalize,
6068 )
6069}
6070
6071fn canonicalize_existing_or_deleted_path_with_memo_and_canonicalizer<F>(
6072 path: &Path,
6073 canonical_parents: &mut ParentCanonicalizationMemo,
6074 canonicalize: &mut F,
6075) -> PathBuf
6076where
6077 F: FnMut(&Path) -> std::io::Result<PathBuf>,
6078{
6079 let Some(parent) = path.parent() else {
6080 return path.to_path_buf();
6081 };
6082 let Some(file_name) = path.file_name() else {
6083 return path.to_path_buf();
6084 };
6085
6086 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
6090 if let Ok(canonical) = canonicalize(path) {
6091 return canonical;
6092 }
6093 }
6094
6095 canonical_parents
6096 .canonicalize_parent_with(parent, canonicalize)
6097 .map(|canonical_parent| canonical_parent.join(file_name))
6098 .unwrap_or_else(|| path.to_path_buf())
6099}
6100
6101fn verify_file_mtimes(
6104 index: &mut SearchIndex,
6105 verify_strategy: cache_freshness::VerifyStrategy,
6106) -> bool {
6107 let filters = PathFilters::default();
6108 let current_files = walk_project_files(&index.project_root, &filters);
6109 let current_file_set: HashSet<PathBuf> = current_files.iter().cloned().collect();
6110 let mut stale_paths = Vec::new();
6111 let mut removed_paths = Vec::new();
6112 let mut changed = false;
6113 let mut canonical_parents = ParentCanonicalizationMemo::default();
6114
6115 for entry in Arc::make_mut(&mut index.files).iter_mut() {
6116 if entry.path.as_os_str().is_empty() {
6117 continue; }
6119 if !current_file_set.contains(&entry.path) {
6120 removed_paths.push(entry.path.clone());
6121 continue;
6122 }
6123 let cached = FileFreshness {
6124 mtime: entry.modified,
6125 size: entry.size,
6126 content_hash: entry.content_hash,
6127 };
6128 let verdict = match verify_strategy {
6129 cache_freshness::VerifyStrategy::StatFirst => {
6130 cache_freshness::verify_file(&entry.path, &cached)
6131 }
6132 cache_freshness::VerifyStrategy::Strict => {
6133 cache_freshness::verify_file_strict(&entry.path, &cached)
6134 }
6135 };
6136 match verdict {
6137 FreshnessVerdict::HotFresh => {}
6138 FreshnessVerdict::ContentFresh {
6139 new_mtime,
6140 new_size,
6141 } => {
6142 entry.modified = new_mtime;
6143 entry.size = new_size;
6144 changed = true;
6145 }
6146 FreshnessVerdict::Stale | FreshnessVerdict::Deleted => {
6147 stale_paths.push(entry.path.clone())
6148 }
6149 }
6150 }
6151
6152 for path in &removed_paths {
6153 index.remove_file_with_canonicalization_memo(path, &mut canonical_parents);
6154 changed = true;
6155 }
6156
6157 for path in &stale_paths {
6161 if current_file_set.contains(path) {
6162 index.update_file_with_canonicalization_memo(path, &mut canonical_parents);
6163 } else {
6164 index.remove_file_with_canonicalization_memo(path, &mut canonical_parents);
6165 }
6166 changed = true;
6167 }
6168
6169 for path in current_files {
6171 if !index.path_to_id.contains_key(&path) {
6172 index.update_file_with_canonicalization_memo(&path, &mut canonical_parents);
6173 changed = true;
6174 }
6175 }
6176
6177 if !stale_paths.is_empty() {
6178 crate::slog_info!(
6179 "search index: refreshed {} stale file(s) from disk cache",
6180 stale_paths.len()
6181 );
6182 }
6183 changed
6184}
6185
6186fn is_within_search_root(search_root: &Path, path: &Path) -> bool {
6187 crate::inspect::job::normalize_path(path)
6188 .starts_with(crate::inspect::job::normalize_path(search_root))
6189}
6190
6191impl QueryBuild {
6192 fn into_query(self) -> RegexQuery {
6193 let mut query = RegexQuery::default();
6194
6195 for run in self.and_runs {
6196 add_run_to_and_query(&mut query, &run);
6197 }
6198
6199 for group in self.or_groups {
6200 let mut trigrams = BTreeSet::new();
6201 let mut filters = HashMap::new();
6202 for run in group {
6203 for (trigram, filter) in trigram_filters(&run) {
6204 trigrams.insert(trigram);
6205 merge_filter(filters.entry(trigram).or_default(), filter);
6206 }
6207 }
6208 if !trigrams.is_empty() {
6209 query.or_groups.push(trigrams.into_iter().collect());
6210 query.or_filters.push(filters);
6211 }
6212 }
6213
6214 query
6215 }
6216}
6217
6218fn build_query(hir: &Hir) -> QueryBuild {
6219 match hir.kind() {
6220 HirKind::Literal(literal) => {
6221 if literal.0.len() >= 3 {
6222 QueryBuild {
6223 and_runs: vec![literal.0.to_vec()],
6224 or_groups: Vec::new(),
6225 }
6226 } else {
6227 QueryBuild::default()
6228 }
6229 }
6230 HirKind::Capture(capture) => build_query(&capture.sub),
6231 HirKind::Concat(parts) => {
6232 let mut build = QueryBuild::default();
6233 for part in parts {
6234 let part_build = build_query(part);
6235 build.and_runs.extend(part_build.and_runs);
6236 build.or_groups.extend(part_build.or_groups);
6237 }
6238 build
6239 }
6240 HirKind::Alternation(parts) => {
6241 let mut group = Vec::new();
6242 for part in parts {
6243 let Some(mut choices) = guaranteed_run_choices(part) else {
6244 return QueryBuild::default();
6245 };
6246 group.append(&mut choices);
6247 }
6248 if group.is_empty() {
6249 QueryBuild::default()
6250 } else {
6251 QueryBuild {
6252 and_runs: Vec::new(),
6253 or_groups: vec![group],
6254 }
6255 }
6256 }
6257 HirKind::Repetition(repetition) => {
6258 if repetition.min == 0 {
6259 QueryBuild::default()
6260 } else {
6261 build_query(&repetition.sub)
6262 }
6263 }
6264 HirKind::Empty | HirKind::Class(_) | HirKind::Look(_) => QueryBuild::default(),
6265 }
6266}
6267
6268fn guaranteed_run_choices(hir: &Hir) -> Option<Vec<Vec<u8>>> {
6269 match hir.kind() {
6270 HirKind::Literal(literal) => {
6271 if literal.0.len() >= 3 {
6272 Some(vec![literal.0.to_vec()])
6273 } else {
6274 None
6275 }
6276 }
6277 HirKind::Capture(capture) => guaranteed_run_choices(&capture.sub),
6278 HirKind::Concat(parts) => {
6279 let mut runs = Vec::new();
6280 for part in parts {
6281 if let Some(mut part_runs) = guaranteed_run_choices(part) {
6282 runs.append(&mut part_runs);
6283 }
6284 }
6285 if runs.is_empty() {
6286 None
6287 } else {
6288 Some(runs)
6289 }
6290 }
6291 HirKind::Alternation(parts) => {
6292 let mut runs = Vec::new();
6293 for part in parts {
6294 let Some(mut part_runs) = guaranteed_run_choices(part) else {
6295 return None;
6296 };
6297 runs.append(&mut part_runs);
6298 }
6299 if runs.is_empty() {
6300 None
6301 } else {
6302 Some(runs)
6303 }
6304 }
6305 HirKind::Repetition(repetition) => {
6306 if repetition.min == 0 {
6307 None
6308 } else {
6309 guaranteed_run_choices(&repetition.sub)
6310 }
6311 }
6312 HirKind::Empty | HirKind::Class(_) | HirKind::Look(_) => None,
6313 }
6314}
6315
6316fn add_run_to_and_query(query: &mut RegexQuery, run: &[u8]) {
6317 for (trigram, filter) in trigram_filters(run) {
6318 if !query.and_trigrams.contains(&trigram) {
6319 query.and_trigrams.push(trigram);
6320 }
6321 merge_filter(query.and_filters.entry(trigram).or_default(), filter);
6322 }
6323}
6324
6325fn trigram_filters(run: &[u8]) -> Vec<(u32, PostingFilter)> {
6326 trigram_filter_map(run, false).into_iter().collect()
6327}
6328
6329fn merge_filter(target: &mut PostingFilter, filter: PostingFilter) {
6330 target.next_mask |= filter.next_mask;
6331 target.loc_mask |= filter.loc_mask;
6332}
6333
6334fn mask_for_next_char(next_char: u8) -> u8 {
6335 let bit = (normalize_char(next_char).wrapping_mul(31) & 7) as u32;
6336 1u8 << bit
6337}
6338
6339fn mask_for_position(position: usize) -> u8 {
6340 1u8 << (position % 8)
6341}
6342
6343fn build_globset(patterns: &[String]) -> Result<Option<GlobSet>, String> {
6344 if patterns.is_empty() {
6345 return Ok(None);
6346 }
6347
6348 let mut builder = GlobSetBuilder::new();
6349 for pattern in patterns {
6350 let glob = Glob::new(pattern).map_err(|error| error.to_string())?;
6351 builder.add(glob);
6352 }
6353 builder.build().map(Some).map_err(|error| error.to_string())
6354}
6355
6356fn read_u32<R: Read>(reader: &mut R) -> std::io::Result<u32> {
6357 let mut buffer = [0u8; 4];
6358 reader.read_exact(&mut buffer)?;
6359 Ok(u32::from_le_bytes(buffer))
6360}
6361
6362fn read_u64<R: Read>(reader: &mut R) -> std::io::Result<u64> {
6363 let mut buffer = [0u8; 8];
6364 reader.read_exact(&mut buffer)?;
6365 Ok(u64::from_le_bytes(buffer))
6366}
6367
6368fn write_u32<W: Write>(writer: &mut W, value: u32) -> std::io::Result<()> {
6369 writer.write_all(&value.to_le_bytes())
6370}
6371
6372fn write_u64<W: Write>(writer: &mut W, value: u64) -> std::io::Result<()> {
6373 writer.write_all(&value.to_le_bytes())
6374}
6375
6376fn verify_crc32_bytes_slice(bytes: &[u8]) -> std::io::Result<()> {
6377 let Some((body, stored)) = bytes.split_last_chunk::<4>() else {
6378 return Err(std::io::Error::other("search index checksum missing"));
6379 };
6380 let expected = u32::from_le_bytes(*stored);
6381 let actual = crc32fast::hash(body);
6382 if actual != expected {
6383 return Err(std::io::Error::other("search index checksum mismatch"));
6384 }
6385 Ok(())
6386}
6387
6388fn remaining_bytes<R: Seek>(reader: &mut R, total_len: usize) -> Option<usize> {
6389 let pos = usize::try_from(reader.stream_position().ok()?).ok()?;
6390 total_len.checked_sub(pos)
6391}
6392
6393fn run_git(root: &Path, args: &[&str]) -> Option<String> {
6394 const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
6395
6396 let mut child = crate::effective_path::new_command("git")
6397 .arg("-C")
6398 .arg(root)
6399 .args(args)
6400 .stdin(std::process::Stdio::null())
6401 .stdout(std::process::Stdio::piped())
6402 .stderr(std::process::Stdio::null())
6403 .spawn()
6404 .ok()?;
6405 let deadline = Instant::now() + GIT_PROBE_TIMEOUT;
6406 let status = loop {
6407 match child.try_wait() {
6408 Ok(Some(status)) => break status,
6409 Ok(None) if Instant::now() >= deadline => {
6410 let _ = child.kill();
6411 let _ = child.wait();
6412 return None;
6413 }
6414 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
6415 Err(_) => {
6416 let _ = child.kill();
6417 let _ = child.wait();
6418 return None;
6419 }
6420 }
6421 };
6422 if !status.success() {
6423 return None;
6424 }
6425 let mut stdout = Vec::new();
6426 child.stdout.take()?.read_to_end(&mut stdout).ok()?;
6427 let value = String::from_utf8(stdout).ok()?;
6428 let value = value.trim().to_string();
6429 (!value.is_empty()).then_some(value)
6430}
6431
6432fn apply_git_diff_updates(index: &mut SearchIndex, root: &Path, from: &str, to: &str) -> bool {
6433 let diff_range = format!("{}..{}", from, to);
6434 let output = match crate::effective_path::new_command("git")
6435 .arg("-C")
6436 .arg(root)
6437 .args(["diff", "--name-status", "-M", &diff_range])
6438 .output()
6439 {
6440 Ok(output) => output,
6441 Err(_) => return false,
6442 };
6443
6444 if !output.status.success() {
6445 return false;
6446 }
6447
6448 let Ok(diff) = String::from_utf8(output.stdout) else {
6449 return false;
6450 };
6451 let mut canonical_parents = ParentCanonicalizationMemo::default();
6452
6453 for line in diff.lines().map(str::trim).filter(|line| !line.is_empty()) {
6454 let mut fields = line.split('\t');
6455 let Some(status) = fields.next() else {
6456 continue;
6457 };
6458
6459 if status.starts_with('R') {
6460 let Some(old_path) = fields.next().and_then(|path| {
6461 cached_path_under_root_with_memo(root, &PathBuf::from(path), &mut canonical_parents)
6462 }) else {
6463 continue;
6464 };
6465 let Some(new_path) = fields.next().and_then(|path| {
6466 cached_path_under_root_with_memo(root, &PathBuf::from(path), &mut canonical_parents)
6467 }) else {
6468 continue;
6469 };
6470 index.remove_file_with_canonicalization_memo(&old_path, &mut canonical_parents);
6471 index.update_file_with_canonicalization_memo(&new_path, &mut canonical_parents);
6472 continue;
6473 }
6474
6475 let Some(path) = fields.next().and_then(|path| {
6476 cached_path_under_root_with_memo(root, &PathBuf::from(path), &mut canonical_parents)
6477 }) else {
6478 continue;
6479 };
6480 if status.starts_with('D') || !path.exists() {
6481 index.remove_file_with_canonicalization_memo(&path, &mut canonical_parents);
6482 } else {
6483 index.update_file_with_canonicalization_memo(&path, &mut canonical_parents);
6484 }
6485 }
6486
6487 true
6488}
6489
6490fn is_binary_path(path: &Path, size: u64) -> bool {
6491 if size == 0 {
6492 return false;
6493 }
6494
6495 let mut file = match File::open(path) {
6496 Ok(file) => file,
6497 Err(_) => return true,
6498 };
6499
6500 let mut preview = vec![0u8; PREVIEW_BYTES.min(size as usize)];
6501 match file.read(&mut preview) {
6502 Ok(read) => is_binary_bytes(&preview[..read]),
6503 Err(_) => true,
6504 }
6505}
6506
6507fn line_starts_bytes(content: &[u8]) -> Vec<usize> {
6508 let mut starts = vec![0usize];
6509 for (index, byte) in content.iter().copied().enumerate() {
6510 if byte == b'\n' {
6511 starts.push(index + 1);
6512 }
6513 }
6514 starts
6515}
6516
6517fn line_details_bytes(content: &[u8], line_starts: &[usize], offset: usize) -> (u32, u32, String) {
6518 let line_index = match line_starts.binary_search(&offset) {
6519 Ok(index) => index,
6520 Err(index) => index.saturating_sub(1),
6521 };
6522 let line_start = line_starts.get(line_index).copied().unwrap_or(0);
6523 let line_end = content[line_start..]
6524 .iter()
6525 .position(|byte| *byte == b'\n')
6526 .map(|length| line_start + length)
6527 .unwrap_or(content.len());
6528 let mut line_slice = &content[line_start..line_end];
6529 if line_slice.ends_with(b"\r") {
6530 line_slice = &line_slice[..line_slice.len() - 1];
6531 }
6532 let line_text = String::from_utf8_lossy(line_slice).into_owned();
6533 let column = String::from_utf8_lossy(&content[line_start..offset])
6534 .chars()
6535 .count() as u32
6536 + 1;
6537 (line_index as u32 + 1, column, line_text)
6538}
6539
6540fn to_glob_path(path: &Path) -> String {
6541 path.to_string_lossy().replace('\\', "/")
6542}
6543
6544#[cfg(test)]
6545mod tests {
6546 use std::process::Command;
6547
6548 use super::*;
6549
6550 fn transient_search_cache_test_dir(root: &Path, name: &str, contents: &[u8]) -> PathBuf {
6551 let path = root.join(name);
6552 fs::create_dir_all(&path).expect("create transient cache fixture");
6553 fs::write(path.join("cache.bin"), contents).expect("write transient cache fixture");
6554 path
6555 }
6556
6557 fn age_transient_search_cache_test_dir(path: &Path) {
6558 let old = SystemTime::now()
6559 .checked_sub(TRANSIENT_SEARCH_CACHE_MIN_AGE + Duration::from_secs(1))
6560 .expect("construct aged transient cache time");
6561 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old))
6562 .expect("age transient cache fixture");
6563 }
6564
6565 #[test]
6566 fn transient_search_cache_name_parses_stable_and_legacy_layouts() {
6567 let key = "0123456789abcdef";
6568 let stable = format!("{TRANSIENT_SEARCH_CACHE_PREFIX}{key}.42");
6569 let legacy = format!("{TRANSIENT_SEARCH_CACHE_PREFIX}{key}.42.123456789");
6570 assert_eq!(
6571 parse_transient_search_cache_name(&stable)
6572 .expect("stable transient cache name")
6573 .pid,
6574 42
6575 );
6576 assert_eq!(
6577 parse_transient_search_cache_name(&legacy)
6578 .expect("legacy transient cache name")
6579 .key,
6580 key
6581 );
6582 assert!(parse_transient_search_cache_name(&format!(
6583 "{TRANSIENT_SEARCH_CACHE_PREFIX}{key}.42.not-a-nanosecond"
6584 ))
6585 .is_none());
6586 }
6587
6588 #[test]
6589 fn transient_builds_reuse_one_cache_dir_and_truncate_previous_contents() {
6590 let project = tempfile::tempdir().expect("project tempdir");
6591 let root = fs::canonicalize(project.path()).expect("canonical project root");
6592 fs::write(root.join("source.rs"), "pub fn transient_cache() {}\n")
6593 .expect("write project source");
6594 let key = artifact_cache_key(&root);
6595 let prefix = format!("{TRANSIENT_SEARCH_CACHE_PREFIX}{key}.");
6596 let temp_root = std::env::temp_dir();
6597 let remove_matching = || {
6598 for entry in fs::read_dir(&temp_root)
6599 .expect("read system temp directory")
6600 .flatten()
6601 {
6602 let name = entry.file_name().to_string_lossy().into_owned();
6603 if name.starts_with(&prefix) && entry.file_type().is_ok_and(|kind| kind.is_dir()) {
6604 let _ = fs::remove_dir_all(entry.path());
6605 }
6606 }
6607 };
6608 remove_matching();
6609
6610 let cache_dir = transient_search_cache_dir(&root);
6611 let first = SearchIndex::build(&root);
6612 assert!(first.ready, "first transient build must complete");
6613 let stale_file = cache_dir.join("must-be-truncated");
6614 fs::write(&stale_file, "old scratch data").expect("plant stale scratch file");
6615 let second = SearchIndex::build(&root);
6616 assert!(second.ready, "second transient build must complete");
6617
6618 let matching = fs::read_dir(&temp_root)
6619 .expect("read system temp directory")
6620 .flatten()
6621 .filter(|entry| {
6622 entry.file_name().to_string_lossy().starts_with(&prefix)
6623 && entry.file_type().is_ok_and(|kind| kind.is_dir())
6624 })
6625 .count();
6626 assert!(
6627 matching <= 1,
6628 "two transient builds may retain at most one process/root cache directory"
6629 );
6630 assert!(
6631 !stale_file.exists(),
6632 "reusing a transient cache directory must clear stale scratch files"
6633 );
6634 drop((first, second));
6635 remove_matching();
6636 }
6637
6638 #[test]
6639 fn transient_search_cache_sweep_reaps_aged_legacy_dir_and_counts_bytes() {
6640 let root = tempfile::tempdir().expect("temporary sweep root");
6641 let legacy = transient_search_cache_test_dir(
6642 root.path(),
6643 "aft-search-cache.1111111111111111.4242.123456789",
6644 b"legacy cache bytes",
6645 );
6646 age_transient_search_cache_test_dir(&legacy);
6647
6648 let summary = sweep_transient_search_cache_dirs_with_limits(
6649 root.path(),
6650 TRANSIENT_SEARCH_CACHE_MIN_AGE,
6651 Duration::from_secs(1),
6652 10,
6653 );
6654
6655 assert!(
6656 !legacy.exists(),
6657 "an aged legacy transient cache must be reaped"
6658 );
6659 assert_eq!(summary.removed, 1);
6660 assert_eq!(summary.bytes, b"legacy cache bytes".len() as u64);
6661 }
6662
6663 #[test]
6664 fn transient_search_cache_sweep_age_guard_spares_fresh_dir_until_aged() {
6665 let root = tempfile::tempdir().expect("temporary sweep root");
6666 let fresh = transient_search_cache_test_dir(
6667 root.path(),
6668 "aft-search-cache.2222222222222222.4242",
6669 b"fresh cache bytes",
6670 );
6671
6672 let fresh_summary = sweep_transient_search_cache_dirs_with_limits(
6673 root.path(),
6674 TRANSIENT_SEARCH_CACHE_MIN_AGE,
6675 Duration::from_secs(1),
6676 10,
6677 );
6678 assert!(fresh.exists(), "the age guard must protect a fresh cache");
6679 assert_eq!(fresh_summary.skipped_fresh, 1);
6680
6681 age_transient_search_cache_test_dir(&fresh);
6682 let aged_summary = sweep_transient_search_cache_dirs_with_limits(
6683 root.path(),
6684 TRANSIENT_SEARCH_CACHE_MIN_AGE,
6685 Duration::from_secs(1),
6686 10,
6687 );
6688 assert!(
6689 !fresh.exists(),
6690 "aging the same directory must make the age guard reap it"
6691 );
6692 assert_eq!(aged_summary.removed, 1);
6693 }
6694
6695 #[test]
6696 fn transient_search_cache_sweep_resumes_after_entry_budget() {
6697 let root = tempfile::tempdir().expect("temporary sweep root");
6698 let first = transient_search_cache_test_dir(
6699 root.path(),
6700 "aft-search-cache.3333333333333333.4242",
6701 b"first cache bytes",
6702 );
6703 let second = transient_search_cache_test_dir(
6704 root.path(),
6705 "aft-search-cache.4444444444444444.4242",
6706 b"second cache bytes",
6707 );
6708 age_transient_search_cache_test_dir(&first);
6709 age_transient_search_cache_test_dir(&second);
6710
6711 let first_pass = sweep_transient_search_cache_dirs_with_limits(
6712 root.path(),
6713 TRANSIENT_SEARCH_CACHE_MIN_AGE,
6714 Duration::from_secs(1),
6715 1,
6716 );
6717 assert!(
6718 first_pass.budget_exhausted,
6719 "the one-entry cap must stop the pass"
6720 );
6721 assert_eq!(first_pass.removed, 1);
6722 assert_eq!(
6723 [first.exists(), second.exists()]
6724 .into_iter()
6725 .filter(|exists| *exists)
6726 .count(),
6727 1,
6728 "the entry budget must leave one aged directory for a later pass"
6729 );
6730
6731 let second_pass = sweep_transient_search_cache_dirs_with_limits(
6732 root.path(),
6733 TRANSIENT_SEARCH_CACHE_MIN_AGE,
6734 Duration::from_secs(1),
6735 10,
6736 );
6737 assert_eq!(second_pass.removed, 1);
6738 assert!(!first.exists() && !second.exists());
6739 }
6740
6741 fn lexical_rank_mixed_storage_fixture() -> (tempfile::TempDir, SearchIndex) {
6742 let dir = tempfile::tempdir().expect("create temp dir");
6743 let project = dir.path().join("project");
6744 fs::create_dir_all(&project).expect("create project dir");
6745 for index in 0..205 {
6746 fs::write(
6747 project.join(format!("file_{index:03}.txt")),
6748 format!("abcdefghij sharedalpha marker_{index}"),
6749 )
6750 .expect("write base fixture file");
6751 }
6752
6753 let cache_dir = dir.path().join("cache");
6754 let mut built = SearchIndex::build(&project);
6755 assert!(built.write_to_disk(&cache_dir, None));
6756 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base index");
6757
6758 let replaced = project.join("file_000.txt");
6759 index.remove_file(&replaced);
6760 index.index_file(&replaced, b"abcdefghij sharedalpha replacement_delta");
6761
6762 let removed = project.join("file_001.txt");
6763 index.remove_file(&removed);
6764
6765 let added = project.join("added_delta.txt");
6766 fs::write(&added, "abcdefghij sharedalpha added_delta").expect("write delta file");
6767 index.index_file(&added, b"abcdefghij sharedalpha added_delta");
6768
6769 assert!(index.base.is_some());
6770 assert!(!index.delta.postings.is_empty());
6771 assert!(!index.delta.superseded.is_empty());
6772 (dir, index)
6773 }
6774
6775 fn postings_for_trigram_materialized_reference(
6776 index: &SearchIndexSnapshot,
6777 trigram: u32,
6778 filter: Option<PostingFilter>,
6779 ) -> Vec<u32> {
6780 let mut matches = Vec::new();
6781 if let Some(base_entry) = index
6782 .base
6783 .as_ref()
6784 .and_then(|base| base.lookup_entry(trigram))
6785 {
6786 if let Some(base) = &index.base {
6787 if let Ok(postings) = base.read_postings(base_entry) {
6788 matches.reserve(postings.len());
6789 for posting in postings {
6790 if index.delta.superseded.contains(&posting.file_id) {
6791 continue;
6792 }
6793 if !posting_matches_filter(&posting, filter) {
6794 continue;
6795 }
6796 if index.is_active_file(posting.file_id) {
6797 matches.push(posting.file_id);
6798 }
6799 }
6800 }
6801 }
6802 }
6803 if let Some(postings) = index.delta.postings.get(&trigram) {
6804 matches.reserve(postings.len());
6805 for posting in postings {
6806 if !posting_matches_filter(posting, filter) {
6807 continue;
6808 }
6809 if index.is_active_file(posting.file_id) {
6810 matches.push(posting.file_id);
6811 }
6812 }
6813 }
6814 if matches.len() > 1 {
6815 matches.sort_unstable();
6816 matches.dedup();
6817 }
6818 matches
6819 }
6820
6821 fn assert_rank_matches_reference(
6822 index: &SearchIndex,
6823 query_trigrams: &[u32],
6824 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
6825 max_files: usize,
6826 ) -> LexicalRankResult {
6827 let snapshot = index.snapshot();
6828 let expected = lexical_rank_with_stats_reference(
6829 &snapshot,
6830 query_trigrams,
6831 candidate_filter,
6832 max_files,
6833 );
6834 let actual = snapshot.lexical_rank_with_stats(query_trigrams, candidate_filter, max_files);
6835 assert_eq!(actual.files, expected.files);
6836 assert_eq!(actual.engine_capped, expected.engine_capped);
6837 actual
6838 }
6839
6840 #[test]
6841 fn cached_path_under_root_allows_missing_lexical_child() {
6842 let dir = tempfile::tempdir().expect("create temp dir");
6843 let project = dir.path().join("project");
6844 fs::create_dir_all(&project).expect("create project dir");
6845 let root = fs::canonicalize(&project).expect("canonicalize project");
6846
6847 let path = cached_path_under_root(&root, Path::new("future/file.rs"))
6848 .expect("missing child should fall back to lexical validation");
6849
6850 assert_eq!(path, root.join("future/file.rs"));
6851 }
6852
6853 #[test]
6854 fn deleted_path_uses_one_parent_canonicalization() {
6855 let dir = tempfile::tempdir().expect("create temp dir");
6856 let project = dir.path().join("project");
6857 let source = project.join("src");
6858 fs::create_dir_all(&source).expect("create source dir");
6859 let deleted = source.join("deleted.rs");
6860 let expected = fs::canonicalize(&source)
6861 .expect("canonicalize source dir")
6862 .join("deleted.rs");
6863 let mut canonical_parents = ParentCanonicalizationMemo::default();
6864 let mut canonicalize_calls = 0;
6865 let mut canonicalize = |path: &Path| {
6866 canonicalize_calls += 1;
6867 fs::canonicalize(path)
6868 };
6869
6870 let resolved = canonicalize_existing_or_deleted_path_with_memo_and_canonicalizer(
6871 &deleted,
6872 &mut canonical_parents,
6873 &mut canonicalize,
6874 );
6875
6876 assert_eq!(resolved, expected);
6877 assert_eq!(canonicalize_calls, 1, "only the parent is canonicalized");
6878 }
6879
6880 #[cfg(unix)]
6881 #[test]
6882 fn cached_path_memo_handles_mixed_entries_without_relaxing_symlink_checks() {
6883 let dir = tempfile::tempdir().expect("create temp dir");
6884 let project = dir.path().join("project");
6885 let source = project.join("src");
6886 let outside = dir.path().join("outside");
6887 fs::create_dir_all(&source).expect("create source dir");
6888 fs::create_dir_all(&outside).expect("create outside dir");
6889 fs::write(source.join("present.rs"), "present").expect("write present file");
6890 fs::write(outside.join("secret.rs"), "secret").expect("write outside file");
6891 std::os::unix::fs::symlink(source.join("present.rs"), project.join("inside.rs"))
6892 .expect("create in-root symlink");
6893 std::os::unix::fs::symlink(outside.join("secret.rs"), project.join("outside.rs"))
6894 .expect("create escaping symlink");
6895 let root = fs::canonicalize(&project).expect("canonicalize project");
6896 let mut canonical_parents = ParentCanonicalizationMemo::default();
6897 let mut canonicalized_paths = Vec::new();
6898 let mut canonicalize = |path: &Path| {
6899 canonicalized_paths.push(path.to_path_buf());
6900 fs::canonicalize(path)
6901 };
6902
6903 assert_eq!(
6904 cached_path_under_root_with_memo_and_canonicalizer(
6905 &root,
6906 Path::new("src/present.rs"),
6907 &mut canonical_parents,
6908 &mut canonicalize,
6909 ),
6910 Some(root.join("src/present.rs"))
6911 );
6912 assert_eq!(
6913 cached_path_under_root_with_memo_and_canonicalizer(
6914 &root,
6915 Path::new("src/deleted.rs"),
6916 &mut canonical_parents,
6917 &mut canonicalize,
6918 ),
6919 Some(root.join("src/deleted.rs"))
6920 );
6921 assert_eq!(
6922 cached_path_under_root_with_memo_and_canonicalizer(
6923 &root,
6924 Path::new("inside.rs"),
6925 &mut canonical_parents,
6926 &mut canonicalize,
6927 ),
6928 Some(root.join("inside.rs"))
6929 );
6930 assert!(cached_path_under_root_with_memo_and_canonicalizer(
6931 &root,
6932 Path::new("outside.rs"),
6933 &mut canonical_parents,
6934 &mut canonicalize,
6935 )
6936 .is_none());
6937
6938 assert_eq!(
6939 canonicalized_paths,
6940 vec![
6941 root.join("src"),
6942 root.join("inside.rs"),
6943 root.join("outside.rs"),
6944 root.clone(),
6945 ],
6946 "siblings reuse their parent while symlinks still resolve individually"
6947 );
6948 }
6949
6950 #[cfg(unix)]
6951 #[test]
6952 fn cached_path_under_root_rejects_symlink_escape() {
6953 let dir = tempfile::tempdir().expect("create temp dir");
6954 let project = dir.path().join("project");
6955 let outside = dir.path().join("outside");
6956 fs::create_dir_all(&project).expect("create project dir");
6957 fs::create_dir_all(&outside).expect("create outside dir");
6958 fs::write(outside.join("secret.txt"), "secret").expect("write outside file");
6959 std::os::unix::fs::symlink(&outside, project.join("link")).expect("create symlink");
6960 let root = fs::canonicalize(&project).expect("canonicalize project");
6961
6962 assert!(cached_path_under_root(&root, Path::new("link/secret.txt")).is_none());
6963 }
6964
6965 #[test]
6966 fn trigram_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
6967 let mut index = SearchIndex::new();
6968 assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
6969 index.index_file(Path::new("memory-estimate.rs"), b"fn memory_estimate() {}");
6970 let estimate = index.estimated_memory();
6971 assert!(estimate.estimated_bytes.unwrap() > 0);
6972 assert!(estimate.counts["delta_postings"] > 0);
6973 assert_eq!(estimate.counts["base_postings_resident_bytes"], 0);
6974 }
6975
6976 #[test]
6977 fn extract_trigrams_tracks_next_char_and_position() {
6978 let trigrams = extract_trigrams(b"Rust");
6979 assert_eq!(trigrams.len(), 2);
6980 assert_eq!(trigrams[0], (pack_trigram(b'r', b'u', b's'), b't', 0));
6981 assert_eq!(
6982 trigrams[1],
6983 (pack_trigram(b'u', b's', b't'), EOF_SENTINEL, 1)
6984 );
6985 }
6986
6987 #[test]
6988 fn index_file_trigram_filters_match_legacy_extraction() {
6989 let dir = tempfile::tempdir().expect("create temp dir");
6990 let path = dir.path().join("sample.txt");
6991 let content = b"Rust rust RUST\nxy";
6992 fs::write(&path, content).expect("write sample");
6993
6994 let mut expected = BTreeMap::new();
6995 for (trigram, next_char, position) in extract_trigrams(content) {
6996 let entry: &mut PostingFilter = expected.entry(trigram).or_default();
6997 entry.next_mask |= mask_for_next_char(next_char);
6998 entry.loc_mask |= mask_for_position(position);
6999 }
7000
7001 let mut index = SearchIndex::new();
7002 index.project_root = dir.path().to_path_buf();
7003 index.index_file(&path, content);
7004
7005 let file_id = *index.path_to_id.get(&path).expect("file indexed");
7006 let file_trigrams = index
7007 .delta_file_trigrams
7008 .get(&file_id)
7009 .expect("delta file trigrams");
7010 assert_eq!(file_trigrams, &expected.keys().copied().collect::<Vec<_>>());
7011 for (trigram, filter) in expected {
7012 let postings = index
7013 .delta
7014 .postings
7015 .get(&trigram)
7016 .expect("delta posting list");
7017 assert_eq!(postings.len(), 1);
7018 assert_eq!(postings[0].file_id, file_id);
7019 assert_eq!(postings[0].next_mask, filter.next_mask);
7020 assert_eq!(postings[0].loc_mask, filter.loc_mask);
7021 }
7022 }
7023
7024 #[test]
7025 fn decompose_regex_extracts_literals_and_alternations() {
7026 let query = decompose_regex("abc(def|ghi)xyz");
7027 assert!(query.and_trigrams.contains(&pack_trigram(b'a', b'b', b'c')));
7028 assert!(query.and_trigrams.contains(&pack_trigram(b'x', b'y', b'z')));
7029 assert_eq!(query.or_groups.len(), 1);
7030 assert!(query.or_groups[0].contains(&pack_trigram(b'd', b'e', b'f')));
7031 assert!(query.or_groups[0].contains(&pack_trigram(b'g', b'h', b'i')));
7032 }
7033
7034 #[test]
7035 fn candidates_intersect_posting_lists() {
7036 let mut index = SearchIndex::new();
7037 let dir = tempfile::tempdir().expect("create temp dir");
7038 let alpha = dir.path().join("alpha.txt");
7039 let beta = dir.path().join("beta.txt");
7040 fs::write(&alpha, "abcdef").expect("write alpha");
7041 fs::write(&beta, "abcxyz").expect("write beta");
7042 index.project_root = dir.path().to_path_buf();
7043 index.index_file(&alpha, b"abcdef");
7044 index.index_file(&beta, b"abcxyz");
7045
7046 let query = RegexQuery {
7047 and_trigrams: vec![
7048 pack_trigram(b'a', b'b', b'c'),
7049 pack_trigram(b'd', b'e', b'f'),
7050 ],
7051 ..RegexQuery::default()
7052 };
7053
7054 let candidates = index.candidates(&query);
7055 assert_eq!(candidates.len(), 1);
7056 assert_eq!(index.files[candidates[0] as usize].path, alpha);
7057 }
7058
7059 #[test]
7060 fn candidates_apply_bloom_filters() {
7061 let mut index = SearchIndex::new();
7062 let dir = tempfile::tempdir().expect("create temp dir");
7063 let file = dir.path().join("sample.txt");
7064 fs::write(&file, "abcd efgh").expect("write sample");
7065 index.project_root = dir.path().to_path_buf();
7066 index.index_file(&file, b"abcd efgh");
7067
7068 let trigram = pack_trigram(b'a', b'b', b'c');
7069 let matching_filter = PostingFilter {
7070 next_mask: mask_for_next_char(b'd'),
7071 loc_mask: mask_for_position(0),
7072 };
7073 let non_matching_filter = PostingFilter {
7074 next_mask: mask_for_next_char(b'z'),
7075 loc_mask: mask_for_position(0),
7076 };
7077
7078 assert_eq!(
7079 index
7080 .postings_for_trigram(trigram, Some(matching_filter))
7081 .len(),
7082 1
7083 );
7084 assert!(index
7085 .postings_for_trigram(trigram, Some(non_matching_filter))
7086 .is_empty());
7087 }
7088
7089 #[test]
7090 fn direct_base_decode_matches_materialized_reference_for_all_storage_and_filters() {
7091 let (_dir, index) = lexical_rank_mixed_storage_fixture();
7092 let snapshot = index.snapshot();
7093 let base_only = pack_trigram(b'm', b'a', b'r');
7094 let base_and_delta = pack_trigram(b'a', b'b', b'c');
7095
7096 assert!(snapshot.delta.postings.get(&base_only).is_none());
7097 assert!(snapshot.delta.postings.contains_key(&base_and_delta));
7098 assert!(!snapshot.delta.superseded.is_empty());
7099
7100 let filters = [
7101 None,
7102 Some(PostingFilter::default()),
7103 Some(PostingFilter {
7104 next_mask: mask_for_next_char(b'd'),
7105 loc_mask: 0,
7106 }),
7107 Some(PostingFilter {
7108 next_mask: mask_for_next_char(b'z'),
7109 loc_mask: 0,
7110 }),
7111 Some(PostingFilter {
7112 next_mask: 0,
7113 loc_mask: mask_for_position(0),
7114 }),
7115 Some(PostingFilter {
7116 next_mask: mask_for_next_char(b'd'),
7117 loc_mask: mask_for_position(17),
7118 }),
7119 Some(PostingFilter {
7120 next_mask: mask_for_next_char(b'z'),
7121 loc_mask: mask_for_position(17),
7122 }),
7123 ];
7124
7125 for trigram in [base_only, base_and_delta] {
7126 for filter in filters {
7127 let expected =
7128 postings_for_trigram_materialized_reference(&snapshot, trigram, filter);
7129 let actual = snapshot.postings_for_trigram(trigram, filter);
7130 assert_eq!(
7131 actual, expected,
7132 "trigram={trigram:#08x}, filter={filter:?}"
7133 );
7134 assert!(actual
7135 .iter()
7136 .all(|file_id| !snapshot.delta.superseded.contains(file_id)));
7137 }
7138 }
7139
7140 let unfiltered = snapshot.postings_for_trigram(base_and_delta, None);
7141 let loc_only = snapshot.postings_for_trigram(
7142 base_and_delta,
7143 Some(PostingFilter {
7144 next_mask: 0,
7145 loc_mask: mask_for_position(31),
7146 }),
7147 );
7148 assert_eq!(loc_only, unfiltered);
7149 }
7150
7151 #[test]
7152 fn base_delta_readd_masks_base_and_keeps_postings_sorted() {
7153 let dir = tempfile::tempdir().expect("create temp dir");
7154 let project = dir.path().join("project");
7155 fs::create_dir_all(&project).expect("create project dir");
7156 let a = project.join("a.txt");
7157 let b = project.join("b.txt");
7158 fs::write(&a, "abc old").expect("write a");
7159 fs::write(&b, "abc base").expect("write b");
7160
7161 let mut built = SearchIndex::build(&project);
7162 let cache_dir = dir.path().join("cache");
7163 built.write_to_disk(&cache_dir, None);
7164 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base");
7165 assert_eq!(index.base_file_count, 2);
7166
7167 let old_a_id = *index.path_to_id.get(&a).expect("original a id");
7168 let b_id = *index.path_to_id.get(&b).expect("original b id");
7169 index.remove_file(&a);
7170 index.index_file(&a, b"abc new");
7171 let new_id = *index.path_to_id.get(&a).expect("re-added file id");
7172 assert!(new_id >= index.base_file_count);
7173 let abc = pack_trigram(b'a', b'b', b'c');
7174 let ids = index.postings_for_trigram(abc, None);
7175 assert_eq!(ids, {
7176 let mut expected = vec![b_id, new_id];
7177 expected.sort_unstable();
7178 expected
7179 });
7180 assert!(!ids.contains(&old_a_id));
7181 }
7182
7183 #[test]
7184 fn snapshot_started_before_edit_keeps_coherent_pre_edit_postings() {
7185 let dir = tempfile::tempdir().expect("create temp dir");
7186 let project = dir.path().join("project");
7187 fs::create_dir_all(&project).expect("create project dir");
7188 let project = fs::canonicalize(project).expect("canonicalize project");
7189 let file = project.join("source.txt");
7190 fs::write(&file, "old_generation marker").expect("write old source");
7191
7192 let mut built = SearchIndex::build(&project);
7193 let cache_dir = dir.path().join("cache");
7194 assert!(built.write_to_disk(&cache_dir, None));
7195 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base index");
7196 index.ready = true;
7197 let old_file_id = *index.path_to_id.get(&file).expect("old file id");
7198 let index = std::sync::RwLock::new(index);
7199
7200 let before_edit = {
7201 let guard = index.read().expect("read index");
7202 let snapshot = guard.snapshot();
7203 assert!(Arc::ptr_eq(&snapshot.delta, &guard.delta));
7204 snapshot
7205 };
7206
7207 fs::write(&file, "new_generation marker").expect("write new source");
7208 index.write().expect("write index").update_file(&file);
7209
7210 let after_edit = index.read().expect("read updated index").snapshot();
7211 let new_file_id = *after_edit.path_to_id.get(&file).expect("new file id");
7212 assert!(!Arc::ptr_eq(&before_edit.delta, &after_edit.delta));
7213 assert!(!before_edit.delta.superseded.contains(&old_file_id));
7214 assert!(after_edit.delta.superseded.contains(&old_file_id));
7215
7216 let old_trigram = pack_trigram(b'o', b'l', b'd');
7217 let new_trigram = pack_trigram(b'n', b'e', b'w');
7218 assert_eq!(
7219 before_edit.postings_for_trigram(old_trigram, None),
7220 vec![old_file_id]
7221 );
7222 assert!(before_edit
7223 .postings_for_trigram(new_trigram, None)
7224 .is_empty());
7225 assert!(after_edit
7226 .postings_for_trigram(old_trigram, None)
7227 .is_empty());
7228 assert_eq!(
7229 after_edit.postings_for_trigram(new_trigram, None),
7230 vec![new_file_id]
7231 );
7232
7233 let dirty_result = index.read().expect("read dirty index").grep(
7234 "new_generation",
7235 true,
7236 &[],
7237 &[],
7238 &project,
7239 100,
7240 );
7241 let reference_cache = dir.path().join("reference-cache");
7242 let reference = SearchIndex::build_with_limit_to_cache_dir(
7243 &project,
7244 DEFAULT_MAX_FILE_SIZE,
7245 &reference_cache,
7246 );
7247 let reference_result = reference.grep("new_generation", true, &[], &[], &project, 100);
7248 assert_eq!(dirty_result.matches, reference_result.matches);
7249 assert_eq!(dirty_result.total_matches, reference_result.total_matches);
7250 }
7251
7252 #[test]
7253 fn lexical_rank_cached_postings_match_reference_for_base_delta_and_superseded_files() {
7254 let (_dir, index) = lexical_rank_mixed_storage_fixture();
7255 let long_query = query_trigrams_from_tokens(&["abcdefghij"]);
7256 assert!(long_query.len() > 3);
7257 let long_result = assert_rank_matches_reference(&index, &long_query, None, 1_000);
7258 assert!(long_result.engine_capped);
7259
7260 let short_query = query_trigrams_from_tokens(&["abc"]);
7261 assert_eq!(short_query.len(), 1);
7262 assert_rank_matches_reference(&index, &short_query, None, 100);
7263
7264 let mixed_query = query_trigrams_from_tokens(&["sharedalpha", "absentzzz"]);
7265 let production_only = |path: &Path| !path.ends_with("file_002.txt");
7266 assert_rank_matches_reference(&index, &mixed_query, Some(&production_only), 25);
7267
7268 let mut duplicate_query = query_trigrams_from_tokens(&["abcdefghij"]);
7269 duplicate_query.push(duplicate_query[0]);
7270 assert_rank_matches_reference(&index, &duplicate_query, None, 40);
7271 }
7272
7273 #[cfg(debug_assertions)]
7274 #[test]
7275 fn lexical_rank_reads_each_distinct_query_posting_list_once() {
7276 let (_dir, index) = lexical_rank_mixed_storage_fixture();
7277 let mut query = query_trigrams_from_tokens(&["abcdefghij", "sharedalpha"]);
7278 query.push(query[0]);
7279 let distinct_trigrams = query.iter().copied().collect::<HashSet<_>>().len();
7280
7281 reset_postings_for_trigram_count_for_debug();
7282 let result = index
7283 .snapshot()
7284 .lexical_rank_with_stats(&query, None, 1_000);
7285
7286 assert!(result.files.len() > 1);
7287 assert_eq!(
7288 postings_for_trigram_count_for_debug(),
7289 distinct_trigrams,
7290 "candidate discovery and scoring must share query-local posting lists"
7291 );
7292 }
7293
7294 #[test]
7295 fn borrow_only_root_skips_shared_lock_persist_and_streaming_spills() {
7296 let dir = tempfile::tempdir().expect("temp dir");
7297 let project = dir.path().join("project");
7298 fs::create_dir_all(&project).expect("project dir");
7299 fs::write(project.join("source.txt"), "borrow only search index").expect("source file");
7300 let project_key = "shared-artifact-key".to_string();
7301 let cache_dir = dir.path().join("index").join(&project_key);
7302 crate::root_cache::configure_artifact_access(&project, &project_key, true);
7303
7304 let _lock = CacheLock::acquire(&cache_dir, &project).expect("borrow-only lock downgrade");
7305 assert!(!cache_dir.join("cache.lock").exists());
7306
7307 let mut index =
7308 SearchIndex::build_with_limit_to_cache_dir(&project, DEFAULT_MAX_FILE_SIZE, &cache_dir);
7309 assert!(!index.ready);
7310 index.write_to_disk(&cache_dir, None);
7311
7312 assert!(!cache_dir.join("cache.bin").exists());
7313 assert!(!cache_dir.exists());
7314 }
7315
7316 #[test]
7317 fn write_to_disk_compacts_base_and_delta() {
7318 let dir = tempfile::tempdir().expect("create temp dir");
7319 let project = dir.path().join("project");
7320 fs::create_dir_all(&project).expect("create project dir");
7321 let file = project.join("src.txt");
7322 fs::write(&file, "abcdef").expect("write source");
7323 let mut index = SearchIndex::build(&project);
7324 let cache_dir = dir.path().join("cache");
7325 index.write_to_disk(&cache_dir, None);
7326 fs::write(&file, "abcxyz").expect("edit source");
7327 index.update_file(&file);
7328 assert!(!index.delta.postings.is_empty());
7329 index.write_to_disk(&cache_dir, None);
7330 assert!(index.delta.postings.is_empty());
7331 assert!(index.delta.superseded.is_empty());
7332 assert_eq!(
7333 index.postings_for_trigram(pack_trigram(b'a', b'b', b'c'), None),
7334 vec![0]
7335 );
7336 assert!(index
7337 .postings_for_trigram(pack_trigram(b'd', b'e', b'f'), None)
7338 .is_empty());
7339 }
7340
7341 #[test]
7342 fn legacy_cache_without_file_trigram_count_migrates_streaming_counts() {
7343 let dir = tempfile::tempdir().expect("create temp dir");
7344 let project = dir.path().join("project");
7345 fs::create_dir_all(&project).expect("create project dir");
7346 fs::write(project.join("src.txt"), "abcdef").expect("write source");
7347 let cache_dir = dir.path().join("cache");
7348 let mut index = SearchIndex::build(&project);
7349 index.write_to_disk(&cache_dir, None);
7350 let cache_path = cache_dir.join("cache.bin");
7351 strip_file_trigram_count_extension(&cache_path);
7352 assert!(!cache_has_file_trigram_count_extension(&cache_path));
7353
7354 let loaded = SearchIndex::read_from_disk(&cache_dir, &project).expect("load legacy cache");
7355 assert_eq!(loaded.file_trigram_count.as_ref(), &[4]);
7356 assert!(loaded.delta.postings.is_empty());
7357 assert!(cache_has_file_trigram_count_extension(&cache_path));
7358 }
7359
7360 #[test]
7361 fn compaction_flags_buffer_paths_while_running() {
7362 let dir = tempfile::tempdir().expect("create temp dir");
7363 let project = dir.path().join("project");
7364 fs::create_dir_all(&project).expect("create project dir");
7365 let file = project.join("src.txt");
7366 fs::write(&file, "abcdef").expect("write source");
7367 let mut index = SearchIndex::new();
7368 index.project_root = project.clone();
7369 {
7370 let mut state = index.compaction_state.lock().expect("compaction state");
7371 state.running = true;
7372 }
7373 index.update_file(&file);
7374 let state = index.compaction_state.lock().expect("compaction state");
7375 assert!(state.requested_again || !index.delta.postings.is_empty());
7376 let canonical_file = fs::canonicalize(&file).expect("canonical source path");
7377 assert!(state.buffered_paths.contains(&canonical_file));
7378 }
7379
7380 fn cache_has_file_trigram_count_extension(cache_path: &Path) -> bool {
7381 file_trigram_count_extension_range(cache_path).is_some()
7382 }
7383
7384 fn strip_file_trigram_count_extension(cache_path: &Path) {
7385 let mut bytes = fs::read(cache_path).expect("read cache");
7386 let (start, end) = file_trigram_count_extension_range_from_bytes(&bytes)
7387 .expect("file trigram count extension");
7388 bytes.drain(start..end);
7389 let postings_len_total = u64::from_le_bytes(bytes[8..16].try_into().unwrap())
7390 - u64::try_from(end - start).unwrap();
7391 bytes[8..16].copy_from_slice(&postings_len_total.to_le_bytes());
7392 let checksum_pos = 16 + usize::try_from(postings_len_total).unwrap() - 4;
7393 let checksum = crc32fast::hash(&bytes[16..checksum_pos]);
7394 bytes[checksum_pos..checksum_pos + 4].copy_from_slice(&checksum.to_le_bytes());
7395 fs::write(cache_path, bytes).expect("write legacy cache");
7396 }
7397
7398 fn file_trigram_count_extension_range(cache_path: &Path) -> Option<(usize, usize)> {
7399 let bytes = fs::read(cache_path).ok()?;
7400 file_trigram_count_extension_range_from_bytes(&bytes)
7401 }
7402
7403 fn file_trigram_count_extension_range_from_bytes(bytes: &[u8]) -> Option<(usize, usize)> {
7404 let postings_len_total = u64::from_le_bytes(bytes.get(8..16)?.try_into().ok()?) as usize;
7405 let postings_start = 16usize;
7406 let postings_end = postings_start.checked_add(postings_len_total)?;
7407 let postings_body_end = postings_end.checked_sub(4)?;
7408 let mut reader = Cursor::new(&bytes[postings_start..postings_body_end]);
7409 let mut magic = [0u8; 8];
7410 reader.read_exact(&mut magic).ok()?;
7411 if &magic != INDEX_MAGIC {
7412 return None;
7413 }
7414 read_u32(&mut reader).ok()?;
7415 let head_len = read_u32(&mut reader).ok()? as u64;
7416 let root_len = read_u32(&mut reader).ok()? as u64;
7417 let ignore_len = read_u32(&mut reader).ok()? as u64;
7418 read_u64(&mut reader).ok()?;
7419 let file_count = read_u32(&mut reader).ok()? as usize;
7420 let skip = head_len.checked_add(root_len)?.checked_add(ignore_len)?;
7421 reader.seek(SeekFrom::Current(skip as i64)).ok()?;
7422 for _ in 0..file_count {
7423 let mut unindexed = [0u8; 1];
7424 reader.read_exact(&mut unindexed).ok()?;
7425 let path_len = read_u32(&mut reader).ok()? as u64;
7426 read_u64(&mut reader).ok()?;
7427 read_u64(&mut reader).ok()?;
7428 read_u32(&mut reader).ok()?;
7429 let mut hash = [0u8; 32];
7430 reader.read_exact(&mut hash).ok()?;
7431 reader.seek(SeekFrom::Current(path_len as i64)).ok()?;
7432 }
7433 let postings_blob_len = read_u64(&mut reader).ok()? as usize;
7434 let extension_start = postings_start
7435 .checked_add(reader.position() as usize)?
7436 .checked_add(postings_blob_len)?;
7437 if extension_start + 16 > postings_body_end {
7438 return None;
7439 }
7440 if bytes.get(extension_start..extension_start + 8)? != FILE_TRIGRAM_COUNT_MAGIC {
7441 return None;
7442 }
7443 let count = u32::from_le_bytes(
7444 bytes[extension_start + 12..extension_start + 16]
7445 .try_into()
7446 .ok()?,
7447 ) as usize;
7448 let extension_end = extension_start
7449 .checked_add(16)?
7450 .checked_add(count.checked_mul(4)?)?;
7451 (extension_end <= postings_body_end).then_some((extension_start, extension_end))
7452 }
7453
7454 #[test]
7455 fn disk_round_trip_preserves_postings_and_files() {
7456 let dir = tempfile::tempdir().expect("create temp dir");
7457 let project = dir.path().join("project");
7458 fs::create_dir_all(&project).expect("create project dir");
7459 let file = project.join("src.txt");
7460 fs::write(&file, "abcdef").expect("write source");
7461
7462 let mut index = SearchIndex::build(&project);
7463 index.git_head = Some("deadbeef".to_string());
7464 let cache_dir = dir.path().join("cache");
7465 let head = index.git_head.clone();
7466 index.write_to_disk(&cache_dir, head.as_deref());
7467
7468 let loaded =
7469 SearchIndex::read_from_disk(&cache_dir, &project).expect("load index from disk");
7470 assert_eq!(loaded.stored_git_head(), Some("deadbeef"));
7471 assert_eq!(loaded.files.len(), 1);
7472 assert_eq!(
7473 relative_to_root(&loaded.project_root, &loaded.files[0].path),
7474 PathBuf::from("src.txt")
7475 );
7476 assert_eq!(loaded.trigram_count(), index.trigram_count());
7477 assert_eq!(
7478 loaded.postings_for_trigram(pack_trigram(b'a', b'b', b'c'), None),
7479 vec![0]
7480 );
7481 assert_eq!(
7482 loaded.file_trigram_count.as_ref(),
7483 index.file_trigram_count.as_ref()
7484 );
7485 }
7486
7487 #[test]
7488 fn cache_path_helpers_reject_absolute_and_parent_paths() {
7489 let root = PathBuf::from("/tmp/aft-project");
7490
7491 assert_eq!(
7492 cache_relative_path(&root, &root.join("src/lib.rs")),
7493 Some(PathBuf::from("src/lib.rs"))
7494 );
7495 assert!(cache_relative_path(&root, Path::new("/tmp/outside.rs")).is_none());
7496 assert!(cached_path_under_root(&root, Path::new("../outside.rs")).is_none());
7497 assert!(cached_path_under_root(&root, Path::new("/tmp/outside.rs")).is_none());
7498 assert_eq!(
7499 cached_path_under_root(&root, Path::new("src/./lib.rs")),
7500 Some(root.join("src/lib.rs"))
7501 );
7502 }
7503
7504 fn git_command_for_test(root: &Path) -> Command {
7505 let mut command = Command::new("git");
7506 crate::test_env::apply_hermetic_git_env(command.arg("-C").arg(root));
7507 command
7508 }
7509
7510 #[test]
7511 fn refresh_after_head_change_removes_renames_and_detects_local_files() {
7512 let _git_env = crate::test_env::hermetic_git_env_guard();
7513 let dir = tempfile::tempdir().expect("create temp dir");
7514 let project = dir.path().join("project");
7515 fs::create_dir_all(&project).expect("create project dir");
7516 let canonical_project = fs::canonicalize(&project).expect("canonical project");
7517 fs::write(project.join("old.txt"), "old token\n").expect("write old");
7518 fs::write(project.join("unchanged.txt"), "before\n").expect("write unchanged");
7519
7520 let mut init = Command::new("git");
7521 crate::test_env::apply_hermetic_git_env(init.arg("init").arg(&project))
7522 .status()
7523 .expect("git init");
7524 for args in [
7525 ["config", "user.email", "aft@example.invalid"],
7526 ["config", "user.name", "AFT Test"],
7527 ] {
7528 git_command_for_test(&project)
7529 .args(args)
7530 .status()
7531 .expect("git config");
7532 }
7533 git_command_for_test(&project)
7534 .args(["add", "."])
7535 .status()
7536 .expect("git add initial");
7537 git_command_for_test(&project)
7538 .args(["commit", "-m", "initial"])
7539 .status()
7540 .expect("git commit initial");
7541 let previous = run_git(&project, &["rev-parse", "HEAD"]).expect("previous head");
7542 let mut baseline = SearchIndex::build(&project);
7543 baseline.git_head = Some(previous.clone());
7544
7545 fs::rename(project.join("old.txt"), project.join("new.txt")).expect("rename file");
7546 git_command_for_test(&project)
7547 .args(["add", "-A"])
7548 .status()
7549 .expect("git add rename");
7550 git_command_for_test(&project)
7551 .args(["commit", "-m", "rename"])
7552 .status()
7553 .expect("git commit rename");
7554 let current = run_git(&project, &["rev-parse", "HEAD"]).expect("current head");
7555
7556 fs::write(project.join("unchanged.txt"), "after local edit\n").expect("local edit");
7557 fs::write(project.join("untracked.txt"), "untracked token\n").expect("untracked");
7558
7559 let refreshed = SearchIndex::rebuild_or_refresh(
7560 &project,
7561 DEFAULT_MAX_FILE_SIZE,
7562 Some(current),
7563 Some(baseline),
7564 None,
7565 );
7566
7567 assert!(!refreshed
7568 .path_to_id
7569 .contains_key(&canonical_project.join("old.txt")));
7570 assert!(refreshed
7571 .path_to_id
7572 .contains_key(&canonical_project.join("new.txt")));
7573 assert!(refreshed
7574 .path_to_id
7575 .contains_key(&canonical_project.join("untracked.txt")));
7576 let matches = refreshed.grep("after local edit", true, &[], &[], &canonical_project, 10);
7577 assert_eq!(matches.matches.len(), 1);
7578 }
7579
7580 #[test]
7581 fn read_from_disk_rejects_corrupt_lookup_checksum() {
7582 let dir = tempfile::tempdir().expect("create temp dir");
7583 let project = dir.path().join("project");
7584 fs::create_dir_all(&project).expect("create project dir");
7585 fs::write(project.join("src.txt"), "abcdef").expect("write source");
7586
7587 let mut index = SearchIndex::build(&project);
7588 let cache_dir = dir.path().join("cache");
7589 index.write_to_disk(&cache_dir, None);
7590
7591 let cache_path = cache_dir.join("cache.bin");
7592 let mut bytes = fs::read(&cache_path).expect("read cache");
7593 let last = bytes.len() - 1;
7594 bytes[last] ^= 0xff;
7595 fs::write(&cache_path, bytes).expect("write corrupted cache");
7596
7597 assert!(SearchIndex::read_from_disk(&cache_dir, &project).is_none());
7598 }
7599
7600 #[test]
7601 fn write_to_disk_uses_temp_files_and_cleans_them_up() {
7602 let dir = tempfile::tempdir().expect("create temp dir");
7603 let project = dir.path().join("project");
7604 fs::create_dir_all(&project).expect("create project dir");
7605 fs::write(project.join("src.txt"), "abcdef").expect("write source");
7606
7607 let mut index = SearchIndex::build(&project);
7608 let cache_dir = dir.path().join("cache");
7609 index.write_to_disk(&cache_dir, None);
7610
7611 assert!(cache_dir.join("cache.bin").is_file());
7612 assert!(fs::read_dir(&cache_dir)
7613 .expect("read cache dir")
7614 .all(|entry| !entry
7615 .expect("cache entry")
7616 .file_name()
7617 .to_string_lossy()
7618 .contains(".tmp.")));
7619 }
7620
7621 #[test]
7622 fn concurrent_search_index_writes_do_not_corrupt() {
7623 let dir = tempfile::tempdir().expect("create temp dir");
7624 let project = dir.path().join("project");
7625 fs::create_dir_all(&project).expect("create project dir");
7626 fs::write(project.join("src.txt"), "abcdef\n").expect("write source");
7627 let cache_dir = dir.path().join("cache");
7628
7629 let a_project = project.clone();
7630 let a_cache = cache_dir.clone();
7631 let a = std::thread::spawn(move || {
7632 let _lock = CacheLock::acquire(&a_cache, &a_project).expect("acquire cache lock a");
7633 let mut index = SearchIndex::build(&a_project);
7634 index.write_to_disk(&a_cache, None);
7635 });
7636 let b_project = project.clone();
7637 let b_cache = cache_dir.clone();
7638 let b = std::thread::spawn(move || {
7639 let _lock = CacheLock::acquire(&b_cache, &b_project).expect("acquire cache lock b");
7640 let mut index = SearchIndex::build(&b_project);
7641 index.write_to_disk(&b_cache, None);
7642 });
7643 a.join().expect("writer a");
7644 b.join().expect("writer b");
7645
7646 assert!(SearchIndex::read_from_disk(&cache_dir, &project).is_some());
7647 }
7648
7649 #[test]
7650 fn search_index_atomic_rename_survives_partial_write() {
7651 let dir = tempfile::tempdir().expect("create temp dir");
7652 let cache_dir = dir.path().join("cache");
7653 fs::create_dir_all(&cache_dir).expect("create cache dir");
7654 fs::write(cache_dir.join("cache.bin.tmp.1.1"), b"partial").expect("write partial tmp");
7655
7656 assert!(SearchIndex::read_from_disk(&cache_dir, dir.path()).is_none());
7657 }
7658
7659 fn grafted_history_test_roots() -> [&'static str; 3] {
7660 [
7661 "7e96b9e0000000000000000000000000000000",
7662 "1e394c20000000000000000000000000000000",
7663 "40587520000000000000000000000000000000",
7664 ]
7665 }
7666
7667 fn artifact_key_from_root_commit_output(stdout: &[u8]) -> String {
7668 match canonicalize_root_commit_output(stdout) {
7669 RootCommitProbe::Commit(root_commit) => artifact_key_from_git_identity(&root_commit),
7670 RootCommitProbe::NoCommit => panic!("root commit output unexpectedly empty"),
7671 RootCommitProbe::NotARepo => panic!("root commit output was not a repository"),
7672 RootCommitProbe::Transient(detail) => panic!("root commit output failed: {detail}"),
7673 }
7674 }
7675
7676 fn force_git_root_commit_probe_outputs_for_test(
7677 outputs_by_root: BTreeMap<PathBuf, Vec<u8>>,
7678 ) -> GitRootCommitProbeOverrideGuard {
7679 install_git_root_commit_probe_override_for_test(move |project_root| {
7680 outputs_by_root
7681 .get(project_root)
7682 .map(|stdout| canonicalize_root_commit_output(stdout))
7683 })
7684 }
7685
7686 #[test]
7687 fn artifact_cache_key_canonicalizes_all_root_permutations() {
7688 let roots = grafted_history_test_roots();
7689 let mut sorted_roots = roots;
7690 sorted_roots.sort_unstable();
7691 let expected_commit = sorted_roots.join("\n");
7692 let expected_key = artifact_key_from_git_identity(&expected_commit);
7693
7694 let permutations = [
7695 [roots[0], roots[1], roots[2]],
7696 [roots[0], roots[2], roots[1]],
7697 [roots[1], roots[0], roots[2]],
7698 [roots[1], roots[2], roots[0]],
7699 [roots[2], roots[0], roots[1]],
7700 [roots[2], roots[1], roots[0]],
7701 ];
7702 for permutation in permutations {
7703 let output = format!("{}\n", permutation.join("\n"));
7704 let RootCommitProbe::Commit(canonical) =
7705 canonicalize_root_commit_output(output.as_bytes())
7706 else {
7707 panic!("root permutation did not produce a commit");
7708 };
7709 assert_eq!(canonical, expected_commit);
7710 assert_eq!(artifact_key_from_git_identity(&canonical), expected_key);
7711 }
7712 }
7713
7714 #[test]
7715 fn artifact_cache_key_deduplicates_repeated_roots() {
7716 let root = grafted_history_test_roots()[0];
7717 let one_root = format!("{root}\n");
7718 let duplicate_root = format!("{root}\n{root}\n");
7719
7720 assert_eq!(
7721 artifact_key_from_root_commit_output(one_root.as_bytes()),
7722 artifact_key_from_root_commit_output(duplicate_root.as_bytes())
7723 );
7724 }
7725
7726 #[test]
7727 fn artifact_cache_key_ignores_blank_whitespace_and_crlf_lines() {
7728 let roots = grafted_history_test_roots();
7729 let clean = format!("{}\n{}\n", roots[0], roots[1]);
7730 let decorated = format!(" \r\n\t{} \r\n{}\t\r\n\r\n", roots[1], roots[0]);
7731
7732 assert_eq!(
7733 artifact_key_from_root_commit_output(clean.as_bytes()),
7734 artifact_key_from_root_commit_output(decorated.as_bytes())
7735 );
7736 }
7737
7738 #[test]
7739 fn artifact_cache_key_single_root_matches_the_old_trimmed_derivation() {
7740 let root = grafted_history_test_roots()[0];
7741 let output = format!("{root}\n");
7742 let old_trimmed = String::from_utf8_lossy(output.as_bytes())
7743 .trim()
7744 .to_string();
7745 let old_key = artifact_hash16(old_trimmed.as_bytes());
7746
7747 let RootCommitProbe::Commit(canonical) = canonicalize_root_commit_output(output.as_bytes())
7748 else {
7749 panic!("single root output did not produce a commit");
7750 };
7751 assert_eq!(canonical, root);
7752 assert_eq!(artifact_key_from_git_identity(&canonical), old_key);
7753 }
7754
7755 #[test]
7756 fn artifact_cache_key_empty_canonical_root_output_is_no_commit() {
7757 assert!(matches!(
7758 canonicalize_root_commit_output(b" \r\n\t\n\r\n"),
7759 RootCommitProbe::NoCommit
7760 ));
7761 }
7762
7763 #[test]
7764 fn artifact_cache_key_memo_round_trip_uses_canonical_root_set() {
7765 let _probe_lock = git_root_commit_probe_override_lock_for_test();
7766 let dir = tempfile::tempdir().expect("create temp dir");
7767 let storage = dir.path().join("storage");
7768 let root = git_like_root(&dir, "repo");
7769 let roots = grafted_history_test_roots();
7770 let mut sorted_roots = roots;
7771 sorted_roots.sort_unstable();
7772 let canonical = sorted_roots.join("\n");
7773 let expected_key = artifact_key_from_git_identity(&canonical);
7774 let first_output = format!("{}\n{}\n{}\n", roots[2], roots[0], roots[1]);
7775 let second_output = format!("{}\n{}\n{}\n", roots[1], roots[2], roots[0]);
7776
7777 {
7778 let mut outputs = BTreeMap::new();
7779 outputs.insert(root.clone(), first_output.into_bytes());
7780 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
7781 assert_eq!(
7782 artifact_cache_key_with_memo(&root, &root, &storage, None)
7783 .expect("first canonical key"),
7784 expected_key
7785 );
7786 }
7787 let first_memo_bytes =
7788 fs::read(artifact_cache_key_memo_path(&storage)).expect("read first memo bytes");
7789
7790 {
7791 let mut outputs = BTreeMap::new();
7792 outputs.insert(root.clone(), second_output.into_bytes());
7793 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
7794 assert_eq!(
7795 artifact_cache_key_with_memo(&root, &root, &storage, None)
7796 .expect("second canonical key"),
7797 expected_key
7798 );
7799 }
7800 let second_memo_bytes =
7801 fs::read(artifact_cache_key_memo_path(&storage)).expect("read second memo bytes");
7802 assert_eq!(
7803 first_memo_bytes, second_memo_bytes,
7804 "an unchanged canonical memo entry should not be rewritten"
7805 );
7806 let memo = read_cache_key_memo(&storage);
7807 assert_eq!(
7808 memo.get(root.to_string_lossy().as_ref())
7809 .expect("canonical memo entry")
7810 .git_root_commit,
7811 canonical
7812 );
7813 }
7814
7815 #[test]
7816 fn artifact_cache_key_replaces_old_unsorted_memo_entry() {
7817 let _probe_lock = git_root_commit_probe_override_lock_for_test();
7818 let dir = tempfile::tempdir().expect("create temp dir");
7819 let storage = dir.path().join("storage");
7820 let root = git_like_root(&dir, "repo");
7821 let roots = grafted_history_test_roots();
7822 let unsorted = format!("{}\n{}", roots[2], roots[0]);
7823 let mut sorted_roots = [roots[2], roots[0]];
7824 sorted_roots.sort_unstable();
7825 let canonical = sorted_roots.join("\n");
7826 let expected_key = artifact_key_from_git_identity(&canonical);
7827
7828 let mut seeded_memo = BTreeMap::new();
7829 seeded_memo.insert(
7830 root.to_string_lossy().into_owned(),
7831 ArtifactCacheKeyMemoEntry {
7832 key: artifact_key_from_git_identity(&unsorted),
7833 git_root_commit: unsorted.clone(),
7834 recorded_at_ms: 1,
7835 },
7836 );
7837 fs::create_dir_all(&storage).expect("create memo storage");
7838 fs::write(
7839 artifact_cache_key_memo_path(&storage),
7840 serde_json::to_vec_pretty(&seeded_memo).expect("serialize seeded memo"),
7841 )
7842 .expect("write seeded memo");
7843
7844 let mut outputs = BTreeMap::new();
7845 outputs.insert(root.clone(), unsorted.into_bytes());
7846 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
7847 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
7848 .expect("canonical probe should replace old memo");
7849
7850 assert_eq!(key, expected_key);
7851 let memo = read_cache_key_memo(&storage);
7852 let entry = memo
7853 .get(root.to_string_lossy().as_ref())
7854 .expect("replaced memo entry");
7855 assert_eq!(entry.key, expected_key);
7856 assert_eq!(entry.git_root_commit, canonical);
7857 }
7858
7859 #[test]
7860 fn artifact_cache_key_shared_across_clones_of_same_repo() {
7861 let _git_env = crate::test_env::hermetic_git_env_guard();
7862 let dir = tempfile::tempdir().expect("create temp dir");
7863 let source = dir.path().join("source");
7864 fs::create_dir_all(&source).expect("create source repo dir");
7865 fs::write(source.join("tracked.txt"), "content\n").expect("write tracked file");
7866
7867 let mut init = Command::new("git");
7868 assert!(
7869 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
7870 .args(["init"])
7871 .status()
7872 .expect("init git repo")
7873 .success()
7874 );
7875 assert!(git_command_for_test(&source)
7876 .args(["add", "."])
7877 .status()
7878 .expect("git add")
7879 .success());
7880 assert!(git_command_for_test(&source)
7881 .args([
7882 "-c",
7883 "user.name=AFT Tests",
7884 "-c",
7885 "user.email=aft-tests@example.com",
7886 "commit",
7887 "-m",
7888 "initial",
7889 ])
7890 .status()
7891 .expect("git commit")
7892 .success());
7893
7894 let clone = dir.path().join("clone");
7895 let mut clone_command = Command::new("git");
7896 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
7897 .args(["clone", "--quiet"])
7898 .arg(&source)
7899 .arg(&clone)
7900 .status()
7901 .expect("git clone")
7902 .success());
7903
7904 let source_key = artifact_cache_key(&source);
7905 let clone_key = artifact_cache_key(&clone);
7906
7907 assert_eq!(source_key.len(), 16);
7908 assert_eq!(clone_key.len(), 16);
7909 assert_eq!(source_key, clone_key);
7911 }
7912
7913 fn read_cache_key_memo(storage_root: &Path) -> BTreeMap<String, ArtifactCacheKeyMemoEntry> {
7914 let bytes = fs::read(artifact_cache_key_memo_path(storage_root)).expect("read memo file");
7915 serde_json::from_slice(&bytes).expect("parse memo file")
7916 }
7917
7918 fn write_cache_key_memo(
7919 storage_root: &Path,
7920 entries: &BTreeMap<String, ArtifactCacheKeyMemoEntry>,
7921 ) {
7922 fs::create_dir_all(storage_root).expect("create memo storage");
7923 fs::write(
7924 artifact_cache_key_memo_path(storage_root),
7925 serde_json::to_vec_pretty(entries).expect("serialize memo"),
7926 )
7927 .expect("write memo");
7928 }
7929
7930 fn git_like_root(dir: &tempfile::TempDir, name: &str) -> PathBuf {
7931 let root = dir.path().join(name);
7932 fs::create_dir_all(root.join(".git")).expect("create git marker");
7933 root
7934 }
7935
7936 #[test]
7937 fn index_orphan_sweep_reclaims_only_aged_unreferenced_dirs() {
7938 let storage = tempfile::tempdir().expect("create storage root");
7939 let index_root = storage.path().join("index");
7940 let aged_orphan = index_root.join("1111111111111111");
7941 let fresh_orphan = index_root.join("2222222222222222");
7942 let memo_referenced = index_root.join("3333333333333333");
7943 for directory in [&aged_orphan, &fresh_orphan, &memo_referenced] {
7944 fs::create_dir_all(directory).expect("create index fixture directory");
7945 fs::write(directory.join("cache.bin"), b"fixture cache").expect("write cache fixture");
7946 }
7947 let old_time = SystemTime::now()
7948 .checked_sub(INDEX_ORPHAN_MIN_AGE + Duration::from_secs(1))
7949 .expect("construct old fixture time");
7950 filetime::set_file_mtime(
7951 aged_orphan.join("cache.bin"),
7952 filetime::FileTime::from_system_time(old_time),
7953 )
7954 .expect("age orphan cache");
7955 filetime::set_file_mtime(
7956 memo_referenced.join("cache.bin"),
7957 filetime::FileTime::from_system_time(old_time),
7958 )
7959 .expect("age referenced cache");
7960 let mut memo = BTreeMap::new();
7961 memo.insert(
7962 "fixture-root".to_string(),
7963 ArtifactCacheKeyMemoEntry {
7964 key: "3333333333333333".to_string(),
7965 git_root_commit: "fixture-commit".to_string(),
7966 recorded_at_ms: current_time_millis(),
7967 },
7968 );
7969 write_cache_key_memo(storage.path(), &memo);
7970
7971 sweep_orphaned_index_dirs(storage.path());
7972
7973 assert!(
7974 !aged_orphan.exists(),
7975 "the aged orphan must be reclaimed by the index sweep"
7976 );
7977 assert!(fresh_orphan.exists(), "fresh index data must be retained");
7978 assert!(
7979 memo_referenced.exists(),
7980 "cache-keys.json references must protect an aged index directory"
7981 );
7982 }
7983
7984 #[test]
7985 fn artifact_cache_key_memo_write_prunes_only_deleted_old_entries() {
7986 let dir = tempfile::tempdir().expect("create temp dir");
7987 let storage = dir.path().join("storage");
7988 let live_old_root = dir.path().join("live-old");
7989 let dead_old_root = dir.path().join("dead-old");
7990 let dead_recent_root = dir.path().join("dead-recent");
7991 let written_root = dir.path().join("written");
7992 fs::create_dir_all(&live_old_root).expect("create live root");
7993 fs::create_dir_all(&written_root).expect("create written root");
7994 let now = current_time_millis();
7995 let old = now.saturating_sub(ARTIFACT_CACHE_KEY_MEMO_EVICTION_AGE.as_millis() as u64 + 1);
7996 let recent =
7997 now.saturating_sub(ARTIFACT_CACHE_KEY_MEMO_EVICTION_AGE.as_millis() as u64 / 2);
7998 let mut seeded = BTreeMap::new();
7999 for (root, key, recorded_at_ms) in [
8000 (&live_old_root, "1111111111111111", old),
8001 (&dead_old_root, "2222222222222222", old),
8002 (&dead_recent_root, "3333333333333333", recent),
8003 ] {
8004 seeded.insert(
8005 root.to_string_lossy().into_owned(),
8006 ArtifactCacheKeyMemoEntry {
8007 key: key.to_string(),
8008 git_root_commit: "fixture-commit".to_string(),
8009 recorded_at_ms,
8010 },
8011 );
8012 }
8013 write_cache_key_memo(&storage, &seeded);
8014
8015 record_artifact_cache_key_memo(
8016 &storage,
8017 written_root.to_string_lossy().as_ref(),
8018 "4444444444444444",
8019 "written-commit",
8020 )
8021 .expect("record memo entry");
8022
8023 let memo = read_cache_key_memo(&storage);
8024 assert!(memo.contains_key(live_old_root.to_string_lossy().as_ref()));
8025 assert!(!memo.contains_key(dead_old_root.to_string_lossy().as_ref()));
8026 assert!(memo.contains_key(dead_recent_root.to_string_lossy().as_ref()));
8027 assert!(memo.contains_key(written_root.to_string_lossy().as_ref()));
8028 }
8029
8030 #[test]
8031 fn artifact_cache_key_memo_prunes_hundreds_of_deleted_entries_on_next_write() {
8032 let dir = tempfile::tempdir().expect("create temp dir");
8033 let storage = dir.path().join("storage");
8034 let written_root = dir.path().join("written");
8035 fs::create_dir_all(&written_root).expect("create written root");
8036 let old = current_time_millis()
8037 .saturating_sub(ARTIFACT_CACHE_KEY_MEMO_EVICTION_AGE.as_millis() as u64 + 1);
8038 let mut seeded = BTreeMap::new();
8039 for index in 0..400 {
8040 seeded.insert(
8041 dir.path()
8042 .join(format!("dead-{index}"))
8043 .to_string_lossy()
8044 .into_owned(),
8045 ArtifactCacheKeyMemoEntry {
8046 key: format!("{index:016x}"),
8047 git_root_commit: format!("fixture-commit-{index}"),
8048 recorded_at_ms: old,
8049 },
8050 );
8051 }
8052 write_cache_key_memo(&storage, &seeded);
8053 let bytes_before = fs::metadata(artifact_cache_key_memo_path(&storage))
8054 .expect("stat seeded memo")
8055 .len();
8056
8057 record_artifact_cache_key_memo(
8058 &storage,
8059 written_root.to_string_lossy().as_ref(),
8060 "aaaaaaaaaaaaaaaa",
8061 "written-commit",
8062 )
8063 .expect("record memo entry");
8064
8065 let memo = read_cache_key_memo(&storage);
8066 let bytes_after = fs::metadata(artifact_cache_key_memo_path(&storage))
8067 .expect("stat pruned memo")
8068 .len();
8069 assert_eq!(
8070 memo.len(),
8071 1,
8072 "next write must remove all stale fixture roots"
8073 );
8074 assert!(memo.contains_key(written_root.to_string_lossy().as_ref()));
8075 assert!(
8076 bytes_after < bytes_before,
8077 "pruning hundreds of dead entries must shrink the memo file"
8078 );
8079 }
8080
8081 #[test]
8082 fn artifact_cache_key_memo_read_hit_refreshes_existing_root_once_per_day() {
8083 let dir = tempfile::tempdir().expect("create temp dir");
8084 let storage = dir.path().join("storage");
8085 let root = dir.path().join("borrowed-root");
8086 fs::create_dir_all(&root).expect("create borrowed root");
8087 let mut seeded = BTreeMap::new();
8088 seeded.insert(
8089 root.to_string_lossy().into_owned(),
8090 ArtifactCacheKeyMemoEntry {
8091 key: "aaaaaaaaaaaaaaaa".to_string(),
8092 git_root_commit: "fixture-commit".to_string(),
8093 recorded_at_ms: 0,
8094 },
8095 );
8096 write_cache_key_memo(&storage, &seeded);
8097
8098 let first = lookup_artifact_cache_key_memo(&storage, root.to_string_lossy().as_ref())
8099 .expect("memo hit");
8100 let persisted_first = read_cache_key_memo(&storage)
8101 .get(root.to_string_lossy().as_ref())
8102 .expect("persisted refreshed entry")
8103 .recorded_at_ms;
8104 let second = lookup_artifact_cache_key_memo(&storage, root.to_string_lossy().as_ref())
8105 .expect("second memo hit");
8106 let persisted_second = read_cache_key_memo(&storage)
8107 .get(root.to_string_lossy().as_ref())
8108 .expect("persisted entry after second hit")
8109 .recorded_at_ms;
8110
8111 assert!(first.recorded_at_ms > 0);
8112 assert_eq!(first.recorded_at_ms, persisted_first);
8113 assert_eq!(second.recorded_at_ms, persisted_second);
8114 assert_eq!(persisted_first, persisted_second);
8115 }
8116
8117 #[test]
8118 fn artifact_cache_key_success_writes_memo() {
8119 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8120 let dir = tempfile::tempdir().expect("create temp dir");
8121 let storage = dir.path().join("storage");
8122 let root = git_like_root(&dir, "repo");
8123 let commit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
8124 let mut commits = BTreeMap::new();
8125 commits.insert(root.clone(), commit.clone());
8126 let _override = force_git_root_commit_probe_commits_for_test(commits);
8127
8128 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
8129 .expect("cache key from successful probe");
8130
8131 assert_eq!(key, artifact_key_from_git_identity(&commit));
8132 let memo = read_cache_key_memo(&storage);
8133 let entry = memo
8134 .get(root.to_string_lossy().as_ref())
8135 .expect("memo entry for root");
8136 assert_eq!(entry.key, key);
8137 assert_eq!(entry.git_root_commit, commit);
8138 assert!(entry.recorded_at_ms > 0);
8139 }
8140
8141 #[test]
8142 fn artifact_cache_key_probe_failure_uses_memoized_key() {
8143 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8144 let dir = tempfile::tempdir().expect("create temp dir");
8145 let storage = dir.path().join("storage");
8146 let root = git_like_root(&dir, "repo");
8147 let commit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
8148 let expected_key = artifact_key_from_git_identity(&commit);
8149 {
8150 let mut commits = BTreeMap::new();
8151 commits.insert(root.clone(), commit);
8152 let _override = force_git_root_commit_probe_commits_for_test(commits);
8153 assert_eq!(
8154 artifact_cache_key_with_memo(&root, &root, &storage, None).expect("initial key"),
8155 expected_key
8156 );
8157 }
8158 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
8159 vec![root.clone()],
8160 "spawn failed: Too many open files (os error 24)",
8161 );
8162
8163 let rescued = artifact_cache_key_with_memo(&root, &root, &storage, None)
8164 .expect("memo should rescue transient probe failure");
8165
8166 assert_eq!(rescued, expected_key);
8167 assert_ne!(rescued, artifact_path_identity_key(&root));
8168 }
8169
8170 #[test]
8171 fn artifact_cache_key_probe_failure_without_memo_rejects_git_like_root() {
8172 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8173 let dir = tempfile::tempdir().expect("create temp dir");
8174 let storage = dir.path().join("storage");
8175 let root = git_like_root(&dir, "repo");
8176 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
8177 vec![root.clone()],
8178 "spawn failed: Too many open files (os error 24)",
8179 );
8180
8181 let error = artifact_cache_key_with_memo(&root, &root, &storage, None)
8182 .expect_err("git-like root without memo must not use path identity");
8183
8184 assert_eq!(error.root(), root.as_path());
8185 assert!(error.detail().contains("Too many open files"));
8186 }
8187
8188 #[test]
8189 fn artifact_cache_key_probe_failure_without_git_marker_uses_path_identity() {
8190 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8191 let dir = tempfile::tempdir().expect("create temp dir");
8192 let storage = dir.path().join("storage");
8193 let root = dir.path().join("plain");
8194 fs::create_dir_all(&root).expect("create non-git root");
8195 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
8196 vec![root.clone()],
8197 "spawn failed: Too many open files (os error 24)",
8198 );
8199
8200 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
8201 .expect("non-git root keeps legacy path identity fallback");
8202
8203 assert_eq!(key, artifact_path_identity_key(&root));
8204 }
8205
8206 #[test]
8207 fn artifact_cache_key_corrupt_memo_is_absent_not_a_path_identity_escape() {
8208 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8209 let dir = tempfile::tempdir().expect("create temp dir");
8210 let storage = dir.path().join("storage");
8211 fs::create_dir_all(&storage).expect("create storage root");
8212 fs::write(artifact_cache_key_memo_path(&storage), b"not json").expect("write corrupt memo");
8213 let root = git_like_root(&dir, "repo");
8214 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
8215 vec![root.clone()],
8216 "spawn failed: Too many open files (os error 24)",
8217 );
8218
8219 let error = artifact_cache_key_with_memo(&root, &root, &storage, None)
8220 .expect_err("corrupt memo is treated as absent");
8221
8222 assert!(error.detail().contains("Too many open files"));
8223 }
8224
8225 #[test]
8226 fn artifact_cache_key_concurrent_memo_writes_keep_valid_json() {
8227 let _probe_lock = git_root_commit_probe_override_lock_for_test();
8228 let dir = tempfile::tempdir().expect("create temp dir");
8229 let storage = dir.path().join("storage");
8230 let root_a = git_like_root(&dir, "repo-a");
8231 let root_b = git_like_root(&dir, "repo-b");
8232 let commit_a = "cccccccccccccccccccccccccccccccccccccccc".to_string();
8233 let commit_b = "dddddddddddddddddddddddddddddddddddddddd".to_string();
8234 let mut commits = BTreeMap::new();
8235 commits.insert(root_a.clone(), commit_a.clone());
8236 commits.insert(root_b.clone(), commit_b.clone());
8237 let _override = force_git_root_commit_probe_commits_for_test(commits);
8238
8239 let storage_a = storage.clone();
8240 let thread_a = std::thread::spawn({
8241 let root_a = root_a.clone();
8242 move || artifact_cache_key_with_memo(&root_a, &root_a, &storage_a, None)
8243 });
8244 let storage_b = storage.clone();
8245 let thread_b = std::thread::spawn({
8246 let root_b = root_b.clone();
8247 move || artifact_cache_key_with_memo(&root_b, &root_b, &storage_b, None)
8248 });
8249
8250 let key_a = thread_a.join().expect("join writer a").expect("key a");
8251 let key_b = thread_b.join().expect("join writer b").expect("key b");
8252 let memo = read_cache_key_memo(&storage);
8253
8254 assert_eq!(
8255 memo.get(root_a.to_string_lossy().as_ref())
8256 .expect("root a memo")
8257 .key,
8258 key_a
8259 );
8260 assert_eq!(
8261 memo.get(root_b.to_string_lossy().as_ref())
8262 .expect("root b memo")
8263 .key,
8264 key_b
8265 );
8266 assert_eq!(key_a, artifact_key_from_git_identity(&commit_a));
8267 assert_eq!(key_b, artifact_key_from_git_identity(&commit_b));
8268 }
8269
8270 #[test]
8271 fn git_head_unchanged_picks_up_local_edits() {
8272 let _git_env = crate::test_env::hermetic_git_env_guard();
8273 let dir = tempfile::tempdir().expect("create temp dir");
8274 let project = dir.path().join("repo");
8275 fs::create_dir_all(&project).expect("create repo dir");
8276 let file = project.join("tracked.txt");
8277 fs::write(&file, "oldtoken\n").expect("write file");
8278 let mut init = Command::new("git");
8279 assert!(
8280 crate::test_env::apply_hermetic_git_env(init.current_dir(&project))
8281 .arg("init")
8282 .status()
8283 .unwrap()
8284 .success()
8285 );
8286 assert!(git_command_for_test(&project)
8287 .args(["add", "."])
8288 .status()
8289 .unwrap()
8290 .success());
8291 assert!(git_command_for_test(&project)
8292 .args([
8293 "-c",
8294 "user.name=AFT Tests",
8295 "-c",
8296 "user.email=aft-tests@example.com",
8297 "commit",
8298 "-m",
8299 "initial"
8300 ])
8301 .status()
8302 .unwrap()
8303 .success());
8304 let head = current_git_head(&project);
8305 let mut baseline = SearchIndex::build(&project);
8306 baseline.git_head = head.clone();
8307 fs::write(&file, "newtoken\n").expect("edit tracked file");
8308
8309 let refreshed = SearchIndex::rebuild_or_refresh(
8310 &project,
8311 DEFAULT_MAX_FILE_SIZE,
8312 head,
8313 Some(baseline),
8314 None,
8315 );
8316 let result = refreshed.grep("newtoken", true, &[], &[], &project, 10);
8317
8318 assert_eq!(result.total_matches, 1);
8319 }
8320
8321 #[test]
8322 fn max_file_size_change_reclassifies_unchanged_files() {
8323 let dir = tempfile::tempdir().expect("create temp dir");
8324 let project = dir.path().join("project");
8325 fs::create_dir_all(&project).expect("create project dir");
8326 let file = project.join("file.txt");
8327 fs::write(&file, "unchanged-limit-token-with-enough-bytes\n").expect("write file");
8328
8329 let indexed = SearchIndex::build_with_limit(&project, 128);
8330 assert_eq!(
8331 indexed
8332 .grep("unchanged-limit-token", true, &[], &[], &project, 10)
8333 .total_matches,
8334 1
8335 );
8336 let lowered = SearchIndex::rebuild_or_refresh(&project, 8, None, Some(indexed), None);
8337 let canonical_file = fs::canonicalize(&file).expect("canonical file");
8338 let lowered_id = *lowered
8339 .path_to_id
8340 .get(&canonical_file)
8341 .expect("lowered file id");
8342 assert!(
8343 lowered.unindexed_files.contains(&lowered_id),
8344 "lowering the limit must classify an unchanged oversized file as unindexed"
8345 );
8346
8347 let raised = SearchIndex::rebuild_or_refresh(&project, 128, None, Some(lowered), None);
8348 let raised_id = *raised
8349 .path_to_id
8350 .get(&canonical_file)
8351 .expect("raised file id");
8352 assert!(
8353 !raised.unindexed_files.contains(&raised_id),
8354 "raising the limit must index a previously unindexed unchanged file"
8355 );
8356 assert_eq!(
8357 raised
8358 .grep("unchanged-limit-token", true, &[], &[], &project, 10)
8359 .total_matches,
8360 1
8361 );
8362 }
8363
8364 #[test]
8365 fn non_git_project_reuses_cache_when_files_unchanged() {
8366 let dir = tempfile::tempdir().expect("create temp dir");
8367 let project = dir.path().join("project");
8368 fs::create_dir_all(&project).expect("create project dir");
8369 fs::write(project.join("file.txt"), "unchangedtoken\n").expect("write file");
8370 let baseline = SearchIndex::build(&project);
8371 let baseline_file_count = baseline.file_count();
8372
8373 let refreshed = SearchIndex::rebuild_or_refresh(
8374 &project,
8375 DEFAULT_MAX_FILE_SIZE,
8376 None,
8377 Some(baseline),
8378 None,
8379 );
8380
8381 assert_eq!(refreshed.file_count(), baseline_file_count);
8382 assert_eq!(
8383 refreshed
8384 .grep("unchangedtoken", true, &[], &[], &project, 10)
8385 .total_matches,
8386 1
8387 );
8388 }
8389
8390 #[test]
8391 fn resolve_search_scope_disables_index_for_external_path() {
8392 let dir = tempfile::tempdir().expect("create temp dir");
8393 let project = dir.path().join("project");
8394 let outside = dir.path().join("outside");
8395 fs::create_dir_all(&project).expect("create project dir");
8396 fs::create_dir_all(&outside).expect("create outside dir");
8397
8398 let scope = resolve_search_scope(&project, outside.to_str());
8399
8400 assert_eq!(
8401 scope.root,
8402 fs::canonicalize(&outside).expect("canonicalize outside")
8403 );
8404 assert!(!scope.use_index);
8405 }
8406
8407 #[test]
8408 fn grep_filters_matches_to_search_root() {
8409 let dir = tempfile::tempdir().expect("create temp dir");
8410 let project = dir.path().join("project");
8411 let src = project.join("src");
8412 let docs = project.join("docs");
8413 fs::create_dir_all(&src).expect("create src dir");
8414 fs::create_dir_all(&docs).expect("create docs dir");
8415 fs::write(src.join("main.rs"), "pub struct SearchIndex;\n").expect("write src file");
8416 fs::write(docs.join("guide.md"), "SearchIndex guide\n").expect("write docs file");
8417
8418 let index = SearchIndex::build(&project);
8419 let result = index.grep("SearchIndex", true, &[], &[], &src, 10);
8420
8421 assert_eq!(result.files_searched, 1);
8422 assert_eq!(result.files_with_matches, 1);
8423 assert_eq!(result.matches.len(), 1);
8424 let expected = fs::canonicalize(src.join("main.rs")).expect("canonicalize");
8426 assert_eq!(result.matches[0].file, expected);
8427 }
8428
8429 #[test]
8430 fn grep_deduplicates_multiple_matches_on_same_line() {
8431 let dir = tempfile::tempdir().expect("create temp dir");
8432 let project = dir.path().join("project");
8433 let src = project.join("src");
8434 fs::create_dir_all(&src).expect("create src dir");
8435 fs::write(src.join("main.rs"), "SearchIndex SearchIndex\n").expect("write src file");
8436
8437 let index = SearchIndex::build(&project);
8438 let result = index.grep("SearchIndex", true, &[], &[], &src, 10);
8439
8440 assert_eq!(result.total_matches, 1);
8441 assert_eq!(result.matches.len(), 1);
8442 }
8443
8444 #[test]
8445 fn grep_case_insensitive_unicode_literal_matches_indexed_file() {
8446 let dir = tempfile::tempdir().expect("create temp dir");
8447 let project = dir.path().join("project");
8448 fs::create_dir_all(&project).expect("create project dir");
8449 let file = project.join("unicode.txt");
8450 fs::write(&file, "äbc\n").expect("write unicode file");
8451
8452 let index = SearchIndex::build(&project);
8453 let result = index.grep("Äbc", false, &[], &[], &project, 10);
8454
8455 assert_eq!(result.total_matches, 1);
8456 assert_eq!(result.matches.len(), 1);
8457 assert_eq!(
8458 result.matches[0].file,
8459 fs::canonicalize(file).expect("canonicalize unicode file")
8460 );
8461 }
8462
8463 #[test]
8464 fn refresh_reindexes_same_size_edit_with_preserved_mtime() {
8465 let dir = tempfile::tempdir().expect("create temp dir");
8466 let project = dir.path().join("project");
8467 fs::create_dir_all(&project).expect("create project dir");
8468 let file = project.join("tokens.txt");
8469 let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
8470 fs::write(&file, "alpha").expect("write original file");
8471 filetime::set_file_mtime(&file, original_mtime).expect("set original mtime");
8472
8473 let baseline = SearchIndex::build(&project);
8474 fs::write(&file, "bravo").expect("write same-size edit");
8475 filetime::set_file_mtime(&file, original_mtime).expect("restore original mtime");
8476
8477 let refreshed = SearchIndex::rebuild_or_refresh(
8478 &project,
8479 DEFAULT_MAX_FILE_SIZE,
8480 None,
8481 Some(baseline),
8482 None,
8483 );
8484 let result = refreshed.grep("bravo", true, &[], &[], &project, 10);
8485 let canonical_file = fs::canonicalize(&file).expect("canonicalize edited file");
8486 let refreshed_id = *refreshed
8487 .path_to_id
8488 .get(&canonical_file)
8489 .expect("file remains indexed");
8490
8491 assert_eq!(result.total_matches, 1);
8492 assert!(refreshed
8493 .postings_for_trigram(pack_trigram(b'b', b'r', b'a'), None)
8494 .contains(&refreshed_id));
8495 assert!(!refreshed
8496 .postings_for_trigram(pack_trigram(b'a', b'l', b'p'), None)
8497 .contains(&refreshed_id));
8498 }
8499
8500 #[test]
8501 fn grep_reports_total_matches_before_truncation() {
8502 let dir = tempfile::tempdir().expect("create temp dir");
8503 let project = dir.path().join("project");
8504 let src = project.join("src");
8505 fs::create_dir_all(&src).expect("create src dir");
8506 fs::write(src.join("main.rs"), "SearchIndex\nSearchIndex\n").expect("write src file");
8507
8508 let index = SearchIndex::build(&project);
8509 let result = index.grep("SearchIndex", true, &[], &[], &src, 1);
8510
8511 assert_eq!(result.total_matches, 2);
8512 assert_eq!(result.matches.len(), 1);
8513 assert!(result.truncated);
8514 }
8515
8516 #[test]
8517 fn glob_filters_results_to_search_root() {
8518 let dir = tempfile::tempdir().expect("create temp dir");
8519 let project = dir.path().join("project");
8520 let src = project.join("src");
8521 let scripts = project.join("scripts");
8522 fs::create_dir_all(&src).expect("create src dir");
8523 fs::create_dir_all(&scripts).expect("create scripts dir");
8524 fs::write(src.join("main.rs"), "pub fn main() {}\n").expect("write src file");
8525 fs::write(scripts.join("tool.rs"), "pub fn tool() {}\n").expect("write scripts file");
8526
8527 let index = SearchIndex::build(&project);
8528 let files = index.glob("**/*.rs", &src);
8529
8530 assert_eq!(
8531 files,
8532 vec![fs::canonicalize(src.join("main.rs")).expect("canonicalize src file")]
8533 );
8534 }
8535
8536 #[test]
8537 fn snapshot_reports_file_presence_without_a_filesystem_walk() {
8538 let dir = tempfile::tempdir().expect("create temp dir");
8539 let project = dir.path().join("project");
8540 let src = project.join("src");
8541 let empty = project.join("empty");
8542 fs::create_dir_all(&src).expect("create src dir");
8543 fs::create_dir_all(&empty).expect("create empty dir");
8544 fs::write(src.join("main.rs"), "pub fn main() {}\n").expect("write src file");
8545
8546 let index = SearchIndex::build(&project);
8547 let snapshot = index.snapshot();
8548
8549 assert!(snapshot.has_file_in_scope(&project));
8550 assert!(snapshot.has_file_in_scope(&src));
8551 assert!(!snapshot.has_file_in_scope(&empty));
8552 }
8553
8554 #[test]
8555 fn glob_includes_hidden_and_binary_files() {
8556 let dir = tempfile::tempdir().expect("create temp dir");
8557 let project = dir.path().join("project");
8558 let hidden_dir = project.join(".hidden");
8559 fs::create_dir_all(&hidden_dir).expect("create hidden dir");
8560 let hidden_file = hidden_dir.join("data.bin");
8561 fs::write(&hidden_file, [0u8, 159, 146, 150]).expect("write binary file");
8562
8563 let index = SearchIndex::build(&project);
8564 let files = index.glob("**/*.bin", &project);
8565
8566 assert_eq!(
8567 files,
8568 vec![fs::canonicalize(hidden_file).expect("canonicalize binary file")]
8569 );
8570 }
8571
8572 #[test]
8573 fn read_from_disk_rejects_invalid_nanos() {
8574 let dir = tempfile::tempdir().expect("create temp dir");
8575 let cache_dir = dir.path().join("cache");
8576 fs::create_dir_all(&cache_dir).expect("create cache dir");
8577
8578 let mut postings = Vec::new();
8579 postings.extend_from_slice(INDEX_MAGIC);
8580 postings.extend_from_slice(&INDEX_VERSION.to_le_bytes());
8581 postings.extend_from_slice(&0u32.to_le_bytes());
8582 postings.extend_from_slice(&1u32.to_le_bytes());
8583 postings.extend_from_slice(&DEFAULT_MAX_FILE_SIZE.to_le_bytes());
8584 postings.extend_from_slice(&1u32.to_le_bytes());
8585 postings.extend_from_slice(b"/");
8586 postings.push(0u8);
8587 postings.extend_from_slice(&1u32.to_le_bytes());
8588 postings.extend_from_slice(&0u64.to_le_bytes());
8589 postings.extend_from_slice(&0u64.to_le_bytes());
8590 postings.extend_from_slice(&1_000_000_000u32.to_le_bytes());
8591 postings.extend_from_slice(b"a");
8592 postings.extend_from_slice(&0u64.to_le_bytes());
8593
8594 let mut lookup = Vec::new();
8595 lookup.extend_from_slice(LOOKUP_MAGIC);
8596 lookup.extend_from_slice(&INDEX_VERSION.to_le_bytes());
8597 lookup.extend_from_slice(&0u32.to_le_bytes());
8598
8599 let postings_checksum = crc32fast::hash(&postings);
8600 postings.extend_from_slice(&postings_checksum.to_le_bytes());
8601 let lookup_checksum = crc32fast::hash(&lookup);
8602 lookup.extend_from_slice(&lookup_checksum.to_le_bytes());
8603 let mut cache = Vec::new();
8604 cache.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
8605 cache.extend_from_slice(&INDEX_VERSION.to_le_bytes());
8606 cache.extend_from_slice(&(postings.len() as u64).to_le_bytes());
8607 cache.extend_from_slice(&postings);
8608 cache.extend_from_slice(&lookup);
8609 fs::write(cache_dir.join("cache.bin"), cache).expect("write cache");
8610
8611 assert!(SearchIndex::read_from_disk(&cache_dir, dir.path()).is_none());
8612 }
8613
8614 #[test]
8615 fn parallel_cold_build_matches_serial_index() {
8616 let dir = tempfile::tempdir().expect("create temp dir");
8617 let project = dir.path().join("project");
8618 for index in 0..80 {
8619 let sub = project.join(format!("pkg_{index:03}"));
8620 fs::create_dir_all(&sub).expect("create subdir");
8621 fs::write(
8622 sub.join("lib.rs"),
8623 format!(
8624 "pub fn unique_marker_{index}() {{ println!(\"aft_perf_marker_{index}\"); }}\n"
8625 ),
8626 )
8627 .expect("write lib");
8628 }
8629
8630 let serial = SearchIndex::build_with_limit_serial(&project, DEFAULT_MAX_FILE_SIZE);
8631 let parallel = SearchIndex::build_with_limit(&project, DEFAULT_MAX_FILE_SIZE);
8632
8633 assert_eq!(serial.file_count(), parallel.file_count());
8634 assert_eq!(serial.trigram_count(), parallel.trigram_count());
8635 assert_eq!(serial.path_to_id.len(), parallel.path_to_id.len());
8636 assert_eq!(
8637 serial.file_trigram_count.as_ref(),
8638 parallel.file_trigram_count.as_ref()
8639 );
8640 for (path, id) in serial.path_to_id.iter() {
8641 assert_eq!(parallel.path_to_id.get(path), Some(id));
8642 }
8643 for (serial_file, parallel_file) in serial.files.iter().zip(parallel.files.iter()) {
8644 assert_eq!(serial_file.path, parallel_file.path);
8645 assert_eq!(serial_file.size, parallel_file.size);
8646 assert_eq!(serial_file.modified, parallel_file.modified);
8647 assert_eq!(serial_file.content_hash, parallel_file.content_hash);
8648 }
8649
8650 let serial_grep = serial.grep("aft_perf_marker_17", true, &[], &[], &project, 10);
8651 let parallel_grep = parallel.grep("aft_perf_marker_17", true, &[], &[], &project, 10);
8652 assert_eq!(serial_grep.matches, parallel_grep.matches);
8653 assert_eq!(serial_grep.total_matches, parallel_grep.total_matches);
8654 assert_eq!(serial_grep.files_searched, parallel_grep.files_searched);
8655 assert_eq!(
8656 serial_grep.files_with_matches,
8657 parallel_grep.files_with_matches
8658 );
8659 }
8660
8661 #[test]
8662 fn ignore_rule_discovery_respects_gitignore() {
8663 let _git_env = crate::test_env::hermetic_git_env_guard();
8664 let dir = tempfile::tempdir().expect("create temp dir");
8665 let project = dir.path().join("project");
8666 fs::create_dir_all(project.join("src")).expect("mkdir src");
8667 fs::write(project.join("src/.gitignore"), "data/\n").expect("write gitignore");
8668 let data = project.join("src/data");
8669 fs::create_dir_all(&data).expect("mkdir data");
8670 for index in 0..200 {
8671 fs::create_dir_all(data.join(format!("d{index}"))).expect("mkdir nested");
8672 fs::write(data.join(format!("d{index}/f.rs")), "fn ignored() {}\n")
8673 .expect("write ignored file");
8674 }
8675
8676 let mut init = Command::new("git");
8677 crate::test_env::apply_hermetic_git_env(init.arg("init").arg(&project))
8678 .status()
8679 .expect("git init");
8680 for args in [
8681 ["config", "user.email", "aft@example.invalid"],
8682 ["config", "user.name", "AFT Test"],
8683 ] {
8684 git_command_for_test(&project)
8685 .args(args)
8686 .status()
8687 .expect("git config");
8688 }
8689 git_command_for_test(&project)
8690 .args(["add", "."])
8691 .status()
8692 .expect("git add");
8693 git_command_for_test(&project)
8694 .args(["commit", "-m", "initial"])
8695 .status()
8696 .expect("git commit");
8697
8698 let legacy_dirs = count_ignore_rule_discovery_dirs_legacy_stack(&project);
8699 let walker_dirs = count_ignore_rule_discovery_dirs(&project);
8700 assert!(
8701 legacy_dirs > walker_dirs,
8702 "legacy stack should descend into gitignored data/ (legacy={legacy_dirs}, walker={walker_dirs})"
8703 );
8704 assert!(
8705 walker_dirs < 50,
8706 "ignore walker should not descend deeply into ignored tree (dirs={walker_dirs})"
8707 );
8708 }
8709
8710 #[test]
8711 fn sort_paths_by_mtime_desc_uses_root_relative_tiebreak() {
8712 let dir = tempfile::tempdir().expect("create tempdir");
8713 let tied_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
8714 let mut paths = ["z-last.rs", "a-first.rs", "m-middle.rs"]
8715 .map(|name| {
8716 let path = dir.path().join(name);
8717 fs::write(&path, format!("// {name}\n")).expect("write fixture");
8718 filetime::set_file_mtime(&path, tied_mtime).expect("pin fixture mtime");
8719 path
8720 })
8721 .to_vec();
8722
8723 sort_paths_by_mtime_desc(&mut paths, dir.path());
8724
8725 let expected = ["a-first.rs", "m-middle.rs", "z-last.rs"]
8726 .map(|name| dir.path().join(name))
8727 .to_vec();
8728 assert_eq!(paths, expected);
8729 }
8730
8731 #[test]
8732 fn walked_path_sort_matches_canonical_sort_for_regular_files() {
8733 let dir = tempfile::tempdir().expect("create tempdir");
8734 let mut paths = Vec::new();
8735 for index in (0..40).rev() {
8736 let path = dir.path().join(format!("module-{index:02}.ts"));
8737 fs::write(&path, format!("export const value = {index};\n")).expect("write fixture");
8738 let mtime = filetime::FileTime::from_unix_time(1_700_000_000 + i64::from(index % 4), 0);
8739 filetime::set_file_mtime(&path, mtime).expect("pin fixture mtime");
8740 paths.push(path);
8741 }
8742
8743 let mut canonicalized = paths.clone();
8744 sort_paths_by_mtime_desc(&mut canonicalized, dir.path());
8745 sort_walked_paths_by_mtime_desc(&mut paths, dir.path());
8746
8747 assert_eq!(paths, canonicalized);
8748 }
8749
8750 #[test]
8752 #[ignore = "manual release-mode fallback glob sort performance probe"]
8753 fn walked_path_sort_perf_probe() {
8754 const DIRECTORIES: usize = 100;
8755 const FILES_PER_DIRECTORY: usize = 100;
8756 const SAMPLES: usize = 9;
8757 const ITERATIONS: usize = 3;
8758
8759 let temp = tempfile::tempdir().expect("create tempdir");
8760 let tied_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
8761 let mut paths = Vec::with_capacity(DIRECTORIES * FILES_PER_DIRECTORY);
8762 for directory in 0..DIRECTORIES {
8763 let path = temp.path().join(format!("package-{directory:03}/src"));
8764 fs::create_dir_all(&path).expect("create package directory");
8765 for file in 0..FILES_PER_DIRECTORY {
8766 let file_path = path.join(format!("module-{file:03}.ts"));
8767 fs::write(&file_path, b"export const value = 1;\n").expect("write fixture");
8768 filetime::set_file_mtime(&file_path, tied_mtime).expect("pin fixture mtime");
8769 paths.push(file_path);
8770 }
8771 }
8772
8773 let mut micros_per_operation = Vec::with_capacity(SAMPLES);
8774 for _ in 0..SAMPLES {
8775 let started = Instant::now();
8776 for _ in 0..ITERATIONS {
8777 let mut sample = paths.clone();
8778 sort_walked_paths_by_mtime_desc(&mut sample, temp.path());
8779 std::hint::black_box(sample);
8780 }
8781 micros_per_operation.push(started.elapsed().as_micros() / ITERATIONS as u128);
8782 }
8783 micros_per_operation.sort_unstable();
8784 let median = micros_per_operation[SAMPLES / 2];
8785
8786 eprintln!(
8787 "fallback glob sort: files={} samples={SAMPLES} iterations={ITERATIONS}",
8788 paths.len()
8789 );
8790 eprintln!("microseconds per glob operation: {micros_per_operation:?}");
8791 eprintln!("median: {median}us per glob operation");
8792 }
8793
8794 #[cfg(windows)]
8795 #[test]
8796 fn sort_paths_by_mtime_desc_normalizes_comparison_paths_without_rewriting_results() {
8797 let dir = tempfile::tempdir().expect("create tempdir");
8798 let canonical_root = fs::canonicalize(dir.path()).expect("canonicalize tempdir");
8799 let tied_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
8800 let canonical_first = canonical_root.join("a-first.rs");
8801 let clean_last = dir.path().join("z-last.rs");
8802 for path in [&canonical_first, &clean_last] {
8803 fs::write(path, "// tied\n").expect("write fixture");
8804 filetime::set_file_mtime(path, tied_mtime).expect("pin fixture mtime");
8805 }
8806 let mut paths = vec![clean_last.clone(), canonical_first.clone()];
8807
8808 sort_paths_by_mtime_desc(&mut paths, &canonical_root);
8809
8810 assert_eq!(paths, vec![canonical_first, clean_last]);
8811 }
8812
8813 #[test]
8828 fn sort_paths_by_mtime_desc_does_not_panic_on_missing_files() {
8829 let dir = tempfile::tempdir().expect("create tempdir");
8833 let mut paths: Vec<PathBuf> = Vec::new();
8834 for i in 0..30 {
8835 let path = if i % 2 == 0 {
8837 let p = dir.path().join(format!("real-{i}.rs"));
8838 fs::write(&p, format!("// {i}\n")).expect("write");
8839 p
8840 } else {
8841 dir.path().join(format!("missing-{i}.rs"))
8842 };
8843 paths.push(path);
8844 }
8845
8846 for _ in 0..50 {
8849 let mut copy = paths.clone();
8850 sort_paths_by_mtime_desc(&mut copy, dir.path());
8851 assert_eq!(copy.len(), paths.len());
8852 }
8853 }
8854
8855 #[test]
8861 fn uncapped_indexed_grep_over_many_files_is_not_engine_capped() {
8862 let dir = tempfile::tempdir().expect("create tempdir");
8863 for i in 0..40 {
8866 fs::write(
8867 dir.path().join(format!("file-{i}.rs")),
8868 format!("fn unique_marker_{i}() {{ let _ = \"needle_token\"; }}\n"),
8869 )
8870 .expect("write");
8871 }
8872 let index = SearchIndex::build_with_limit(dir.path(), DEFAULT_MAX_FILE_SIZE);
8873 let result = index.grep("needle_token", false, &[], &[], dir.path(), 1000);
8874 assert!(
8875 result.matches.len() >= 40,
8876 "expected a match per file, got {}",
8877 result.matches.len()
8878 );
8879 assert!(
8880 !result.engine_capped,
8881 "an uncapped grep over >10 files must not report engine_capped"
8882 );
8883 assert!(!result.truncated, "uncapped grep must not be truncated");
8884 }
8885
8886 #[test]
8890 fn sort_grep_matches_by_mtime_desc_does_not_panic_on_missing_files() {
8891 let dir = tempfile::tempdir().expect("create tempdir");
8892 let mut matches: Vec<GrepMatch> = Vec::new();
8893 for i in 0..30 {
8894 let file = if i % 2 == 0 {
8895 let p = dir.path().join(format!("real-{i}.rs"));
8896 fs::write(&p, format!("// {i}\n")).expect("write");
8897 p
8898 } else {
8899 dir.path().join(format!("missing-{i}.rs"))
8900 };
8901 matches.push(GrepMatch {
8902 file,
8903 line: u32::try_from(i).unwrap_or(0),
8904 column: 0,
8905 line_text: format!("match {i}"),
8906 match_text: format!("match {i}"),
8907 });
8908 }
8909
8910 for _ in 0..50 {
8911 let mut copy = matches.clone();
8912 sort_grep_matches_by_mtime_desc(&mut copy, dir.path());
8913 assert_eq!(copy.len(), matches.len());
8914 }
8915 }
8916
8917 #[test]
8918 fn out_of_order_delta_refresh_matches_full_sort_reference() {
8919 const FILES: u32 = 1_024;
8920 let shared_trigram = pack_trigram(b's', b'h', b'r');
8921 let mut optimized = Vec::new();
8922 let mut reference = Vec::new();
8923
8924 for file_id in (0..FILES).rev() {
8925 let posting = Posting {
8926 file_id,
8927 next_mask: 0,
8928 loc_mask: 0,
8929 };
8930 insert_delta_posting(&mut optimized, posting.clone());
8931 insert_delta_posting_full_sort_reference(&mut reference, posting);
8932 }
8933
8934 assert_eq!(
8935 optimized, reference,
8936 "delta postings must stay file-id sorted"
8937 );
8938
8939 let mut index = SearchIndex::new();
8940 let files = Arc::make_mut(&mut index.files);
8941 for file_id in 0..FILES {
8942 files.push(FileEntry {
8943 path: PathBuf::from(format!("/delta/file-{file_id:04}.rs")),
8944 size: 0,
8945 modified: UNIX_EPOCH,
8946 content_hash: cache_freshness::zero_hash(),
8947 });
8948 }
8949 Arc::make_mut(&mut index.delta)
8950 .postings
8951 .insert(shared_trigram, optimized);
8952
8953 let actual = index.candidates(&RegexQuery {
8954 and_trigrams: vec![shared_trigram],
8955 ..RegexQuery::default()
8956 });
8957 let expected = reference
8958 .into_iter()
8959 .map(|posting| posting.file_id)
8960 .collect::<Vec<_>>();
8961 assert_eq!(
8962 serde_json::to_vec(&actual).expect("serialize candidates"),
8963 serde_json::to_vec(&expected).expect("serialize reference candidates"),
8964 "candidate IDs must match the full-sort reference byte-for-byte"
8965 );
8966 }
8967
8968 #[test]
8969 #[ignore = "manual release-mode issue #219 delta insertion performance probe"]
8970 fn issue_219_delta_insertion_perf_probe() {
8971 const FILES: u32 = 1_024;
8972 const SAMPLES: usize = 9;
8973 const ITERATIONS: usize = 8;
8974
8975 let reference_once = || {
8976 let mut postings = Vec::with_capacity(FILES as usize);
8977 for file_id in (0..FILES).rev() {
8978 insert_delta_posting_full_sort_reference(
8979 &mut postings,
8980 Posting {
8981 file_id,
8982 next_mask: 0,
8983 loc_mask: 0,
8984 },
8985 );
8986 }
8987 std::hint::black_box(postings);
8988 };
8989 let optimized_once = || {
8990 let mut postings = Vec::with_capacity(FILES as usize);
8991 for file_id in (0..FILES).rev() {
8992 insert_delta_posting(
8993 &mut postings,
8994 Posting {
8995 file_id,
8996 next_mask: 0,
8997 loc_mask: 0,
8998 },
8999 );
9000 }
9001 std::hint::black_box(postings);
9002 };
9003
9004 let mut reference_ns = Vec::with_capacity(SAMPLES);
9005 let mut optimized_ns = Vec::with_capacity(SAMPLES);
9006 for sample in 0..SAMPLES {
9007 let measure = |operation: &dyn Fn()| {
9008 let started = Instant::now();
9009 for _ in 0..ITERATIONS {
9010 operation();
9011 }
9012 started.elapsed().as_nanos() / ITERATIONS as u128
9013 };
9014 if sample % 2 == 0 {
9015 reference_ns.push(measure(&reference_once));
9016 optimized_ns.push(measure(&optimized_once));
9017 } else {
9018 optimized_ns.push(measure(&optimized_once));
9019 reference_ns.push(measure(&reference_once));
9020 }
9021 }
9022 reference_ns.sort_unstable();
9023 optimized_ns.sort_unstable();
9024 let reference_median = reference_ns[SAMPLES / 2];
9025 let optimized_median = optimized_ns[SAMPLES / 2];
9026 let speedup = reference_median as f64 / optimized_median as f64;
9027
9028 eprintln!(
9029 "issue #219 delta insertion: files={FILES} samples={SAMPLES} iterations={ITERATIONS}"
9030 );
9031 eprintln!("full-sort ns/refresh samples: {reference_ns:?}");
9032 eprintln!("binary-insert ns/refresh samples: {optimized_ns:?}");
9033 eprintln!(
9034 "median: full-sort={reference_median}ns binary-insert={optimized_median}ns speedup={speedup:.2}x"
9035 );
9036 }
9037}
9038
9039#[cfg(test)]
9040mod warm_reload_verification_tests {
9041 use super::*;
9042
9043 #[test]
9044 fn warm_disk_verification_uses_stat_first_and_hashes_changed_stats() {
9045 let dir = tempfile::tempdir().unwrap();
9046 let root = fs::canonicalize(dir.path()).unwrap();
9047 let path = root.join("warm.rs");
9048 fs::write(&path, "fn warm_reload() {}\n").unwrap();
9049 let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
9050 filetime::set_file_mtime(&path, original_mtime).unwrap();
9051 let mut index = SearchIndex::build(&root);
9052
9053 cache_freshness::watch_hash_file_for_debug(&path);
9054 index.verify_against_disk_with_strategy(None, cache_freshness::VerifyStrategy::StatFirst);
9055 assert_eq!(cache_freshness::watched_hash_file_count_for_debug(), 0);
9056
9057 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
9058 cache_freshness::watch_hash_file_for_debug(&path);
9059 index.verify_against_disk_with_strategy(None, cache_freshness::VerifyStrategy::StatFirst);
9060 assert_eq!(cache_freshness::watched_hash_file_count_for_debug(), 1);
9061 }
9062
9063 #[test]
9064 fn write_denied_cold_build_flags_build_denied_instead_of_building() {
9065 let project = tempfile::tempdir().expect("project");
9071 let source = project.path().join("lib.rs");
9072 fs::write(&source, "pub fn answer() -> i32 { 42 }\n").expect("write source");
9073 let project_key = "shared-search-artifact".to_string();
9074 crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
9075
9076 let storage = tempfile::tempdir().expect("storage");
9078 let cache_dir = storage.path().join(&project_key);
9079 let index = SearchIndex::build_with_limit_to_cache_dir(
9080 project.path(),
9081 DEFAULT_MAX_FILE_SIZE,
9082 &cache_dir,
9083 );
9084
9085 assert!(
9086 index.build_denied,
9087 "a write-denied cold build must flag build_denied so health can report a settled state"
9088 );
9089 assert!(
9090 !index.ready,
9091 "build_denied must keep ready=false so grep/glob keep using the bounded fallback walk"
9092 );
9093 assert!(
9094 index.files.is_empty(),
9095 "a write-denied build must not materialize an in-RAM index"
9096 );
9097 }
9098}
9099
9100#[cfg(test)]
9101mod interactive_artifact_read_budget_tests {
9102 use super::*;
9103 use std::sync::Arc;
9104 use std::thread;
9105
9106 #[test]
9107 fn contended_read_returns_within_interactive_budget() {
9108 let lock = Arc::new(RwLock::new(()));
9109 let writer = lock.write().expect("acquire test writer");
9110 let reader_lock = Arc::clone(&lock);
9111 let reader = thread::spawn(move || {
9112 let started = Instant::now();
9113 let result = try_read_with_budget(&reader_lock, Duration::from_millis(20));
9114 (result.is_some(), started.elapsed())
9115 });
9116
9117 thread::sleep(Duration::from_millis(75));
9118 drop(writer);
9119 let (acquired, elapsed) = reader.join().expect("join bounded reader");
9120 assert!(!acquired, "a live writer must force bounded degradation");
9121 assert!(
9122 elapsed < Duration::from_millis(60),
9123 "contended read exceeded its 20ms budget: {elapsed:?}"
9124 );
9125 }
9126}