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