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