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