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