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