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