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