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