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 = match pattern {
1701 CompiledPattern::Regex {
1702 case_insensitive: true,
1703 ..
1704 } => {
1705 decompose_regex(&format!("(?i:{raw_pattern})"))
1709 }
1710 _ => decompose_regex(&raw_pattern),
1711 };
1712 let fully_degraded = query.and_trigrams.is_empty() && query.or_groups.is_empty();
1713 let candidate_ids = self.candidates(&query);
1714 let trigram_lookup = trigram_started.elapsed();
1715
1716 let candidate_filter_started = Instant::now();
1717 let candidate_files: Vec<&FileEntry> = candidate_ids
1718 .into_iter()
1719 .filter_map(|file_id| self.files.get(file_id as usize))
1720 .filter(|file| !file.path.as_os_str().is_empty())
1721 .filter(|file| is_within_search_root(&search_root, &file.path))
1722 .filter(|file| {
1723 path_exclusion.is_none_or(|exclude| !exclude(&file.path, &self.project_root))
1724 })
1725 .filter(|file| filters.matches(&self.project_root, &file.path))
1726 .collect();
1727 let candidate_count = candidate_files.len();
1728 let candidate_filter = candidate_filter_started.elapsed();
1729
1730 let total_matches = AtomicUsize::new(0);
1731 let files_searched = AtomicUsize::new(0);
1732 let files_with_matches = AtomicUsize::new(0);
1733 let bytes_verified = AtomicUsize::new(0);
1734 let truncated = AtomicBool::new(false);
1735 let engine_capped = AtomicBool::new(false);
1736 let stop_after = max_results.saturating_mul(2);
1737 let stop_scan = Arc::new(AtomicBool::new(false));
1738 let verification_started = Instant::now();
1739 let verification_claims = AtomicUsize::new(0);
1740 let claim_verification = || {
1741 let Some((max_files, budget)) = verification_limits else {
1742 return true;
1743 };
1744 if verification_started.elapsed() >= budget {
1745 return false;
1746 }
1747 verification_claims.fetch_add(1, Ordering::Relaxed) < max_files
1748 };
1749
1750 let pread_started = Instant::now();
1751 let mut matches = if candidate_files.len() > 10 {
1752 candidate_files
1753 .par_iter()
1754 .map(|file| {
1755 if grep_scan_should_stop(
1756 Some(&stop_scan),
1757 &truncated,
1758 &total_matches,
1759 stop_after,
1760 ) {
1761 engine_capped.store(true, Ordering::Relaxed);
1762 return Vec::new();
1763 }
1764 if !claim_verification() {
1765 truncated.store(true, Ordering::Relaxed);
1766 engine_capped.store(true, Ordering::Relaxed);
1767 stop_scan.store(true, Ordering::Relaxed);
1768 return Vec::new();
1769 }
1770 search_candidate_file(
1771 file,
1772 &matcher,
1773 max_results,
1774 stop_after,
1775 &total_matches,
1776 &files_searched,
1777 &files_with_matches,
1778 &bytes_verified,
1779 &truncated,
1780 &engine_capped,
1781 Some(&stop_scan),
1782 )
1783 })
1784 .reduce(Vec::new, |mut left, mut right| {
1785 left.append(&mut right);
1789 left
1790 })
1791 } else {
1792 let mut matches = Vec::new();
1793 for file in candidate_files {
1794 if !claim_verification() {
1795 truncated.store(true, Ordering::Relaxed);
1796 engine_capped.store(true, Ordering::Relaxed);
1797 break;
1798 }
1799 matches.extend(search_candidate_file(
1800 file,
1801 &matcher,
1802 max_results,
1803 stop_after,
1804 &total_matches,
1805 &files_searched,
1806 &files_with_matches,
1807 &bytes_verified,
1808 &truncated,
1809 &engine_capped,
1810 None,
1811 ));
1812
1813 if should_stop_search(&truncated, &total_matches, stop_after) {
1814 engine_capped.store(true, Ordering::Relaxed);
1815 break;
1816 }
1817 }
1818 matches
1819 };
1820 let pread_verify = pread_started.elapsed();
1821
1822 let post_filter_started = Instant::now();
1823 sort_shared_grep_matches_by_cached_mtime_desc(&mut matches, &self.project_root, |path| {
1824 self.path_to_id
1825 .get(path)
1826 .and_then(|file_id| self.files.get(*file_id as usize))
1827 .map(|file| file.modified)
1828 });
1829
1830 let matches = matches
1831 .into_iter()
1832 .map(|matched| GrepMatch {
1833 file: matched.file.as_ref().clone(),
1834 line: matched.line,
1835 column: matched.column,
1836 line_text: matched.line_text,
1837 match_text: matched.match_text,
1838 })
1839 .collect();
1840
1841 let result = GrepResult {
1842 total_matches: total_matches.load(Ordering::Relaxed),
1843 matches,
1844 files_searched: files_searched.load(Ordering::Relaxed),
1845 files_with_matches: files_with_matches.load(Ordering::Relaxed),
1846 index_status: if self.ready {
1847 IndexStatus::Ready
1848 } else {
1849 IndexStatus::Building
1850 },
1851 truncated: truncated.load(Ordering::Relaxed),
1852 fully_degraded,
1853 engine_capped: engine_capped.load(Ordering::Relaxed),
1854 walk_truncated: false,
1855 };
1856 let post_filter = candidate_filter + post_filter_started.elapsed();
1857 let phases = GrepQueryPhaseTimings {
1858 trigram_lookup,
1859 pread_verify,
1860 post_filter,
1861 candidate_count,
1862 bytes_verified: bytes_verified.load(Ordering::Relaxed),
1863 };
1864 (result, phases)
1865 }
1866
1867 fn empty_grep_result(&self) -> GrepResult {
1868 GrepResult {
1869 matches: Vec::new(),
1870 total_matches: 0,
1871 files_searched: 0,
1872 files_with_matches: 0,
1873 index_status: if self.ready {
1874 IndexStatus::Ready
1875 } else {
1876 IndexStatus::Building
1877 },
1878 truncated: false,
1879 fully_degraded: false,
1880 engine_capped: false,
1881 walk_truncated: false,
1882 }
1883 }
1884
1885 pub fn glob(&self, pattern: &str, search_root: &Path) -> Vec<PathBuf> {
1886 self.glob_profiled(pattern, search_root, true).0
1887 }
1888
1889 pub(crate) fn glob_profiled(
1890 &self,
1891 pattern: &str,
1892 search_root: &Path,
1893 sort_by_mtime: bool,
1894 ) -> (Vec<PathBuf>, bool, usize) {
1895 let filters = match build_path_filters(&[pattern.to_string()], &[]) {
1896 Ok(filters) => filters,
1897 Err(_) => return (Vec::new(), false, 0),
1898 };
1899 let search_root = canonicalize_for_search_membership(search_root);
1900 let entries_visited = self.files.len();
1901 let mut scope_has_files = false;
1902 let mut entries = self
1903 .files
1904 .iter()
1905 .filter(|file| !file.path.as_os_str().is_empty())
1906 .filter(|file| {
1907 let in_scope = is_within_search_root(&search_root, &file.path);
1908 scope_has_files |= in_scope;
1909 in_scope
1910 })
1911 .filter(|file| filters.matches(&self.project_root, &file.path))
1912 .map(|file| (file.path.clone(), file.modified))
1913 .collect::<Vec<_>>();
1914
1915 if sort_by_mtime {
1916 entries.sort_by(|(left_path, left_mtime), (right_path, right_mtime)| {
1917 right_mtime
1918 .cmp(left_mtime)
1919 .then_with(|| left_path.cmp(right_path))
1920 });
1921 }
1922
1923 (
1924 entries.into_iter().map(|(path, _)| path).collect(),
1925 scope_has_files,
1926 entries_visited,
1927 )
1928 }
1929
1930 pub fn candidates(&self, query: &RegexQuery) -> Vec<u32> {
1931 if query.and_trigrams.is_empty() && query.or_groups.is_empty() {
1932 return self.active_file_ids();
1933 }
1934
1935 let mut and_trigrams = query.and_trigrams.clone();
1936 and_trigrams.sort_unstable_by_key(|trigram| self.posting_count(*trigram));
1937
1938 let mut current: Option<Vec<u32>> = None;
1939
1940 for trigram in and_trigrams {
1941 let filter = query.and_filters.get(&trigram).copied();
1942 let matches = self.postings_for_trigram(trigram, filter);
1943 current = Some(match current.take() {
1944 Some(existing) => intersect_sorted_ids(&existing, &matches),
1945 None => matches,
1946 });
1947
1948 if current.as_ref().is_some_and(|ids| ids.is_empty()) {
1949 break;
1950 }
1951 }
1952
1953 let mut current = current.unwrap_or_else(|| self.active_file_ids());
1954
1955 for (index, group) in query.or_groups.iter().enumerate() {
1956 let mut group_matches = Vec::new();
1957 let filters = query.or_filters.get(index);
1958
1959 for trigram in group {
1960 let filter = filters.and_then(|filters| filters.get(trigram).copied());
1961 let matches = self.postings_for_trigram(*trigram, filter);
1962 if group_matches.is_empty() {
1963 group_matches = matches;
1964 } else {
1965 group_matches = union_sorted_ids(&group_matches, &matches);
1966 }
1967 }
1968
1969 current = intersect_sorted_ids(¤t, &group_matches);
1970 if current.is_empty() {
1971 break;
1972 }
1973 }
1974
1975 let mut unindexed = self
1976 .unindexed_files
1977 .iter()
1978 .copied()
1979 .filter(|file_id| self.is_active_file(*file_id))
1980 .collect::<Vec<_>>();
1981 if !unindexed.is_empty() {
1982 unindexed.sort_unstable();
1983 current = union_sorted_ids(¤t, &unindexed);
1984 }
1985
1986 current
1987 }
1988
1989 fn posting_count(&self, trigram: u32) -> usize {
1990 let base_count = self
1991 .base
1992 .as_ref()
1993 .and_then(|base| base.lookup_entry(trigram))
1994 .map_or(0usize, |entry| entry.count as usize);
1995 base_count.saturating_add(self.delta.postings.get(&trigram).map_or(0usize, Vec::len))
1996 }
1997
1998 fn active_file_ids(&self) -> Vec<u32> {
1999 let mut ids: Vec<u32> = self.path_to_id.values().copied().collect();
2000 ids.retain(|file_id| self.is_active_file(*file_id));
2001 ids.sort_unstable();
2002 ids
2003 }
2004
2005 fn is_active_file(&self, file_id: u32) -> bool {
2006 if self.delta.superseded.contains(&file_id) {
2007 return false;
2008 }
2009 self.files
2010 .get(file_id as usize)
2011 .map(|file| !file.path.as_os_str().is_empty())
2012 .unwrap_or(false)
2013 }
2014
2015 fn postings_for_trigram(&self, trigram: u32, filter: Option<PostingFilter>) -> Vec<u32> {
2016 #[cfg(debug_assertions)]
2017 POSTINGS_FOR_TRIGRAM_CALLS.with(|calls| calls.set(calls.get().saturating_add(1)));
2018
2019 let mut matches = Vec::new();
2020
2021 if let Some(base_entry) = self
2022 .base
2023 .as_ref()
2024 .and_then(|base| base.lookup_entry(trigram))
2025 {
2026 if let Some(base) = &self.base {
2027 matches.reserve(base_entry.count as usize);
2028 let _ = base.for_each_posting(base_entry, |file_id, next_mask, loc_mask| {
2029 if self.delta.superseded.contains(&file_id) {
2030 return;
2031 }
2032 let posting = Posting {
2033 file_id,
2034 next_mask,
2035 loc_mask,
2036 };
2037 if !posting_matches_filter(&posting, filter) {
2038 return;
2039 }
2040 if self.is_active_file(file_id) {
2041 matches.push(file_id);
2042 }
2043 });
2044 }
2045 }
2046
2047 if let Some(postings) = self.delta.postings.get(&trigram) {
2048 matches.reserve(postings.len());
2049 for posting in postings {
2050 if !posting_matches_filter(posting, filter) {
2051 continue;
2052 }
2053 if self.is_active_file(posting.file_id) {
2054 matches.push(posting.file_id);
2055 }
2056 }
2057 }
2058
2059 if matches.len() > 1 {
2060 matches.sort_unstable();
2061 matches.dedup();
2062 }
2063 matches
2064 }
2065}
2066
2067fn posting_matches_filter(posting: &Posting, filter: Option<PostingFilter>) -> bool {
2068 if let Some(filter) = filter {
2069 if filter.next_mask != 0 && posting.next_mask & filter.next_mask == 0 {
2072 return false;
2073 }
2074 }
2078 true
2079}
2080
2081fn search_candidate_file(
2082 file: &FileEntry,
2083 matcher: &SearchMatcher,
2084 max_results: usize,
2085 stop_after: usize,
2086 total_matches: &AtomicUsize,
2087 files_searched: &AtomicUsize,
2088 files_with_matches: &AtomicUsize,
2089 bytes_verified: &AtomicUsize,
2090 truncated: &AtomicBool,
2091 engine_capped: &AtomicBool,
2092 stop_scan: Option<&Arc<AtomicBool>>,
2093) -> Vec<SharedGrepMatch> {
2094 if grep_scan_should_stop(stop_scan, truncated, total_matches, stop_after) {
2095 engine_capped.store(true, Ordering::Relaxed);
2096 return Vec::new();
2097 }
2098
2099 let content = match read_indexed_file_bytes(&file.path) {
2100 Some(content) => content,
2101 None => return Vec::new(),
2102 };
2103 bytes_verified.fetch_add(content.len(), Ordering::Relaxed);
2104 if is_binary_bytes(&content) {
2111 return Vec::new();
2112 }
2113 files_searched.fetch_add(1, Ordering::Relaxed);
2114
2115 let shared_path = Arc::new(file.path.clone());
2116 let mut matches = Vec::new();
2117 let mut line_starts = None;
2118 let mut seen_lines = HashSet::new();
2119 let mut matched_this_file = false;
2120
2121 match matcher {
2122 SearchMatcher::Literal(literal) if !literal.case_insensitive_ascii => {
2123 let needle = &literal.needle;
2124 let finder = memchr::memmem::Finder::new(needle);
2125 let mut start = 0;
2126
2127 while let Some(position) = finder.find(&content[start..]) {
2128 if grep_scan_should_stop(stop_scan, truncated, total_matches, stop_after) {
2129 engine_capped.store(true, Ordering::Relaxed);
2130 break;
2131 }
2132
2133 let offset = start + position;
2134 start = offset + 1;
2135
2136 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2137 let (line, column, line_text) = line_details_bytes(&content, line_starts, offset);
2138 if !seen_lines.insert(line) {
2139 continue;
2140 }
2141
2142 matched_this_file = true;
2143 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2144 if match_number > max_results {
2145 truncated.store(true, Ordering::Relaxed);
2146 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2147 break;
2148 }
2149
2150 let end = offset + needle.len();
2151 matches.push(SharedGrepMatch {
2152 file: shared_path.clone(),
2153 line,
2154 column,
2155 line_text,
2156 match_text: String::from_utf8_lossy(&content[offset..end]).into_owned(),
2157 });
2158 }
2159 }
2160 SearchMatcher::Literal(literal) => {
2161 let needle = &literal.needle;
2162 let search_content = content.to_ascii_lowercase();
2163 let finder = memchr::memmem::Finder::new(needle);
2164 let mut start = 0;
2165
2166 while let Some(position) = finder.find(&search_content[start..]) {
2167 if grep_scan_should_stop(stop_scan, truncated, total_matches, stop_after) {
2168 engine_capped.store(true, Ordering::Relaxed);
2169 break;
2170 }
2171
2172 let offset = start + position;
2173 start = offset + 1;
2174
2175 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2176 let (line, column, line_text) = line_details_bytes(&content, line_starts, offset);
2177 if !seen_lines.insert(line) {
2178 continue;
2179 }
2180
2181 matched_this_file = true;
2182 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2183 if match_number > max_results {
2184 truncated.store(true, Ordering::Relaxed);
2185 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2186 break;
2187 }
2188
2189 let end = offset + needle.len();
2190 matches.push(SharedGrepMatch {
2191 file: shared_path.clone(),
2192 line,
2193 column,
2194 line_text,
2195 match_text: String::from_utf8_lossy(&content[offset..end]).into_owned(),
2196 });
2197 }
2198 }
2199 SearchMatcher::Regex(regex) => {
2200 for matched in regex.find_iter(&content) {
2201 if grep_scan_should_stop(stop_scan, truncated, total_matches, stop_after) {
2202 engine_capped.store(true, Ordering::Relaxed);
2203 break;
2204 }
2205
2206 let line_starts = line_starts.get_or_insert_with(|| line_starts_bytes(&content));
2207 let (line, column, line_text) =
2208 line_details_bytes(&content, line_starts, matched.start());
2209 if !seen_lines.insert(line) {
2210 continue;
2211 }
2212
2213 matched_this_file = true;
2214 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
2215 if match_number > max_results {
2216 truncated.store(true, Ordering::Relaxed);
2217 signal_grep_scan_cap(stop_scan, total_matches, stop_after);
2218 break;
2219 }
2220
2221 matches.push(SharedGrepMatch {
2222 file: shared_path.clone(),
2223 line,
2224 column,
2225 line_text,
2226 match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
2227 });
2228 }
2229 }
2230 }
2231
2232 if matched_this_file {
2233 files_with_matches.fetch_add(1, Ordering::Relaxed);
2234 }
2235
2236 matches
2237}
2238
2239fn should_stop_search(
2240 truncated: &AtomicBool,
2241 total_matches: &AtomicUsize,
2242 stop_after: usize,
2243) -> bool {
2244 truncated.load(Ordering::Relaxed) && total_matches.load(Ordering::Relaxed) >= stop_after
2245}
2246
2247fn grep_scan_should_stop(
2248 stop_scan: Option<&Arc<AtomicBool>>,
2249 truncated: &AtomicBool,
2250 total_matches: &AtomicUsize,
2251 stop_after: usize,
2252) -> bool {
2253 stop_scan.is_some_and(|flag| flag.load(Ordering::Relaxed))
2254 || should_stop_search(truncated, total_matches, stop_after)
2255}
2256
2257fn signal_grep_scan_cap(
2258 stop_scan: Option<&Arc<AtomicBool>>,
2259 total_matches: &AtomicUsize,
2260 stop_after: usize,
2261) {
2262 if let Some(flag) = stop_scan {
2263 if total_matches.load(Ordering::Relaxed) >= stop_after {
2264 flag.store(true, Ordering::Relaxed);
2265 }
2266 }
2267}
2268
2269fn search_file_metadata(metadata: &fs::Metadata) -> SearchFileMetadata {
2270 SearchFileMetadata {
2271 size: metadata.len(),
2272 modified: metadata.modified().unwrap_or(UNIX_EPOCH),
2273 }
2274}
2275
2276fn metadata_for_indexed_content(path: &Path, size_hint: u64) -> SearchFileMetadata {
2277 fs::metadata(path)
2278 .ok()
2279 .map(|metadata| search_file_metadata(&metadata))
2280 .unwrap_or(SearchFileMetadata {
2281 size: size_hint,
2282 modified: UNIX_EPOCH,
2283 })
2284}
2285
2286fn prepare_search_path(path: &Path, max_file_size: u64) -> PreparedSearchPath {
2287 let metadata = match fs::metadata(path) {
2288 Ok(metadata) if metadata.is_file() => search_file_metadata(&metadata),
2289 _ => return PreparedSearchPath::Skipped,
2290 };
2291
2292 if is_binary_path(path, metadata.size) || metadata.size > max_file_size {
2293 return PreparedSearchPath::Unindexed(metadata);
2294 }
2295
2296 let content = match fs::read(path) {
2297 Ok(content) => content,
2298 Err(_) => return PreparedSearchPath::Skipped,
2299 };
2300
2301 if is_binary_bytes(&content) {
2302 return PreparedSearchPath::Unindexed(metadata);
2303 }
2304
2305 PreparedSearchPath::Indexed(PreparedIndexedFile {
2306 metadata,
2307 content_hash: cache_freshness::hash_bytes(&content),
2308 trigram_map: trigram_filter_map(&content, true),
2309 })
2310}
2311
2312fn search_index_build_pool_size() -> usize {
2315 std::thread::available_parallelism()
2316 .map(|parallelism| parallelism.get())
2317 .unwrap_or(1)
2318 .div_ceil(2)
2319 .clamp(1, 8)
2320}
2321
2322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2323struct SpillRecord {
2324 trigram: u32,
2325 file_id: u32,
2326 next_mask: u8,
2327 loc_mask: u8,
2328}
2329
2330struct CacheWritePlan {
2331 project_root: PathBuf,
2332 git_head: Option<String>,
2333 ignore_fingerprint: String,
2334 max_file_size: u64,
2335 files: Vec<FileEntry>,
2336 path_to_id: HashMap<PathBuf, u32>,
2337 unindexed_files: HashSet<u32>,
2338 file_trigram_count: Vec<u32>,
2339 id_map: Arc<HashMap<u32, u32>>,
2340}
2341
2342impl CacheWritePlan {
2343 fn from_index(index: &SearchIndex, git_head: Option<&str>) -> Option<Self> {
2344 let active_ids = index.active_file_ids();
2345 let mut id_map = HashMap::with_capacity(active_ids.len());
2346 for (new_id, old_id) in active_ids.iter().enumerate() {
2347 let new_id = u32::try_from(new_id).ok()?;
2348 id_map.insert(*old_id, new_id);
2349 }
2350
2351 let mut files = Vec::with_capacity(active_ids.len());
2352 let mut path_to_id = HashMap::with_capacity(active_ids.len());
2353 let mut unindexed_files = HashSet::new();
2354 let mut file_trigram_count = Vec::with_capacity(active_ids.len());
2355 for old_id in active_ids {
2356 let new_id = *id_map.get(&old_id)?;
2357 let file = index.files.get(old_id as usize)?.clone();
2358 if file.path.as_os_str().is_empty() {
2359 continue;
2360 }
2361 path_to_id.insert(file.path.clone(), new_id);
2362 if index.unindexed_files.contains(&old_id) {
2363 unindexed_files.insert(new_id);
2364 }
2365 file_trigram_count.push(
2366 index
2367 .file_trigram_count
2368 .get(old_id as usize)
2369 .copied()
2370 .unwrap_or(0),
2371 );
2372 files.push(file);
2373 }
2374
2375 Some(Self {
2376 project_root: index.project_root.clone(),
2377 git_head: git_head.map(ToOwned::to_owned),
2378 ignore_fingerprint: if index.ignore_rules_fingerprint.is_empty() {
2379 ignore_rules_fingerprint(&index.project_root)
2380 } else {
2381 index.ignore_rules_fingerprint.clone()
2382 },
2383 max_file_size: index.max_file_size,
2384 files,
2385 path_to_id,
2386 unindexed_files,
2387 file_trigram_count,
2388 id_map: Arc::new(id_map),
2389 })
2390 }
2391}
2392
2393trait PostingRecordSource {
2394 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>>;
2395}
2396
2397struct VecRecordSource {
2398 records: Vec<SpillRecord>,
2399 index: usize,
2400}
2401
2402impl VecRecordSource {
2403 fn new(records: Vec<SpillRecord>) -> Self {
2404 Self { records, index: 0 }
2405 }
2406}
2407
2408impl PostingRecordSource for VecRecordSource {
2409 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
2410 let record = self.records.get(self.index).copied();
2411 if record.is_some() {
2412 self.index += 1;
2413 }
2414 Ok(record)
2415 }
2416}
2417
2418struct SpillSegmentSource {
2419 reader: BufReader<File>,
2420 remaining_records: u64,
2421 current_trigram: u32,
2422 remaining_in_group: u32,
2423}
2424
2425impl SpillSegmentSource {
2426 fn open(path: &Path) -> std::io::Result<Self> {
2427 let mut reader = BufReader::new(File::open(path)?);
2428 let mut magic = [0u8; 8];
2429 reader.read_exact(&mut magic)?;
2430 if &magic != SPILL_MAGIC {
2431 return Err(std::io::Error::other("invalid search spill magic"));
2432 }
2433 if read_u32(&mut reader)? != INDEX_VERSION {
2434 return Err(std::io::Error::other("invalid search spill version"));
2435 }
2436 let remaining_records = read_u64(&mut reader)?;
2437 Ok(Self {
2438 reader,
2439 remaining_records,
2440 current_trigram: 0,
2441 remaining_in_group: 0,
2442 })
2443 }
2444}
2445
2446impl PostingRecordSource for SpillSegmentSource {
2447 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
2448 if self.remaining_records == 0 {
2449 return Ok(None);
2450 }
2451 if self.remaining_in_group == 0 {
2452 self.current_trigram = read_u32(&mut self.reader)?;
2453 self.remaining_in_group = read_u32(&mut self.reader)?;
2454 if self.remaining_in_group == 0 {
2455 return Err(std::io::Error::other("empty search spill group"));
2456 }
2457 }
2458 let mut file_id = [0u8; 4];
2459 self.reader.read_exact(&mut file_id)?;
2460 let mut masks = [0u8; 2];
2461 self.reader.read_exact(&mut masks)?;
2462 self.remaining_in_group -= 1;
2463 self.remaining_records -= 1;
2464 Ok(Some(SpillRecord {
2465 trigram: self.current_trigram,
2466 file_id: u32::from_le_bytes(file_id),
2467 next_mask: masks[0],
2468 loc_mask: masks[1],
2469 }))
2470 }
2471}
2472
2473struct BaseRecordSource {
2474 base: Arc<BasePostings>,
2475 id_map: Arc<HashMap<u32, u32>>,
2476 delta: Arc<DeltaState>,
2477 lookup_index: usize,
2478 current: Vec<SpillRecord>,
2479 current_index: usize,
2480}
2481
2482impl BaseRecordSource {
2483 fn new(
2484 base: Arc<BasePostings>,
2485 id_map: Arc<HashMap<u32, u32>>,
2486 delta: Arc<DeltaState>,
2487 ) -> Self {
2488 Self {
2489 base,
2490 id_map,
2491 delta,
2492 lookup_index: 0,
2493 current: Vec::new(),
2494 current_index: 0,
2495 }
2496 }
2497
2498 fn load_next_group(&mut self) -> std::io::Result<bool> {
2499 while let Some(entry) = self.base.lookup.get(self.lookup_index).copied() {
2500 self.lookup_index += 1;
2501 let postings = self.base.read_postings(entry)?;
2502 self.current.clear();
2503 self.current_index = 0;
2504 for posting in postings {
2505 if self.delta.superseded.contains(&posting.file_id) {
2506 continue;
2507 }
2508 let Some(mapped_file_id) = self.id_map.get(&posting.file_id).copied() else {
2509 continue;
2510 };
2511 self.current.push(SpillRecord {
2512 trigram: entry.trigram,
2513 file_id: mapped_file_id,
2514 next_mask: posting.next_mask,
2515 loc_mask: posting.loc_mask,
2516 });
2517 }
2518 if !self.current.is_empty() {
2519 return Ok(true);
2520 }
2521 }
2522 Ok(false)
2523 }
2524}
2525
2526impl PostingRecordSource for BaseRecordSource {
2527 fn next_record(&mut self) -> std::io::Result<Option<SpillRecord>> {
2528 if self.current_index >= self.current.len() && !self.load_next_group()? {
2529 return Ok(None);
2530 }
2531 let record = self.current[self.current_index];
2532 self.current_index += 1;
2533 Ok(Some(record))
2534 }
2535}
2536
2537#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2538struct HeapItem {
2539 record: SpillRecord,
2540 source_index: usize,
2541}
2542
2543impl Ord for HeapItem {
2544 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2545 other
2546 .record
2547 .trigram
2548 .cmp(&self.record.trigram)
2549 .then_with(|| other.record.file_id.cmp(&self.record.file_id))
2550 .then_with(|| other.source_index.cmp(&self.source_index))
2551 }
2552}
2553
2554impl PartialOrd for HeapItem {
2555 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2556 Some(self.cmp(other))
2557 }
2558}
2559
2560fn build_streaming_index(
2561 root: &Path,
2562 max_file_size: u64,
2563 cache_dir: &Path,
2564) -> std::io::Result<(SearchIndex, usize)> {
2565 fs::create_dir_all(cache_dir)?;
2566 sweep_stale_search_build_dirs(cache_dir);
2567 let project_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
2568 let ignore_fingerprint = ignore_rules_fingerprint(&project_root);
2569 let filters = PathFilters::default();
2570 let paths: Vec<PathBuf> = walk_project_files(&project_root, &filters);
2571 let pool_size = search_index_build_pool_size();
2572 let chunk_size = pool_size.saturating_mul(4).clamp(1, 32);
2573 let pool = rayon::ThreadPoolBuilder::new()
2574 .num_threads(pool_size)
2575 .thread_name(|index| format!("aft-search-build-{index}"))
2576 .stack_size(8 * 1024 * 1024)
2577 .build()
2578 .ok();
2579
2580 let spill_dir = create_spill_dir(cache_dir)?;
2581 let mut spill_paths = Vec::new();
2582 let mut spill_seq = 0usize;
2583 let mut block: Vec<SpillRecord> = Vec::new();
2584 let mut files = Vec::new();
2585 let mut path_to_id = HashMap::new();
2586 let mut unindexed_files = HashSet::new();
2587 let mut file_trigram_count = Vec::new();
2588 let mut indexed = 0usize;
2589
2590 let build_result = (|| -> std::io::Result<BasePostings> {
2591 for chunk in paths.chunks(chunk_size) {
2592 let prepare_chunk = || -> Vec<PreparedSearchPath> {
2593 chunk
2594 .par_iter()
2595 .map(|path| prepare_search_path(path, max_file_size))
2596 .collect()
2597 };
2598 let prepared = match &pool {
2599 Some(pool) => pool.install(prepare_chunk),
2600 None => prepare_chunk(),
2601 };
2602
2603 for (path, prepared) in chunk.iter().zip(prepared) {
2604 match prepared {
2605 PreparedSearchPath::Indexed(file) => {
2606 let file_id = u32::try_from(files.len())
2607 .map_err(|_| std::io::Error::other("too many files to index"))?;
2608 files.push(FileEntry {
2609 path: path.clone(),
2610 size: file.metadata.size,
2611 modified: file.metadata.modified,
2612 content_hash: file.content_hash,
2613 });
2614 path_to_id.insert(path.clone(), file_id);
2615 file_trigram_count.push(file.trigram_map.len() as u32);
2616 for (trigram, filter) in file.trigram_map {
2617 block.push(SpillRecord {
2618 trigram,
2619 file_id,
2620 next_mask: filter.next_mask,
2621 loc_mask: filter.loc_mask,
2622 });
2623 }
2624 indexed += 1;
2625 }
2626 PreparedSearchPath::Unindexed(metadata) => {
2627 let file_id = u32::try_from(files.len())
2628 .map_err(|_| std::io::Error::other("too many files to index"))?;
2629 files.push(FileEntry {
2630 path: path.clone(),
2631 size: metadata.size,
2632 modified: metadata.modified,
2633 content_hash: cache_freshness::zero_hash(),
2634 });
2635 path_to_id.insert(path.clone(), file_id);
2636 unindexed_files.insert(file_id);
2637 file_trigram_count.push(0);
2638 indexed += 1;
2639 }
2640 PreparedSearchPath::Skipped => {}
2641 }
2642
2643 let block_bytes = block.len().saturating_mul(SPILL_RECORD_ESTIMATED_BYTES);
2644 if block_bytes >= SPIMI_SOFT_LIMIT_BYTES || block_bytes >= SPIMI_HARD_LIMIT_BYTES {
2645 let path = flush_spill_segment(&spill_dir, spill_seq, &mut block)?;
2646 spill_paths.push(path);
2647 spill_seq += 1;
2648 }
2649 }
2650 }
2651
2652 block.sort_unstable_by_key(|record| (record.trigram, record.file_id));
2653 let mut sources: Vec<Box<dyn PostingRecordSource>> = Vec::new();
2654 for path in &spill_paths {
2655 sources.push(Box::new(SpillSegmentSource::open(path)?));
2656 }
2657 if !block.is_empty() {
2658 sources.push(Box::new(VecRecordSource::new(std::mem::take(&mut block))));
2659 }
2660
2661 let plan = CacheWritePlan {
2662 project_root: project_root.clone(),
2663 git_head: current_git_head(&project_root),
2664 ignore_fingerprint: ignore_fingerprint.clone(),
2665 max_file_size,
2666 files: files.clone(),
2667 path_to_id: path_to_id.clone(),
2668 unindexed_files: unindexed_files.clone(),
2669 file_trigram_count: file_trigram_count.clone(),
2670 id_map: Arc::new(
2671 (0..files.len())
2672 .filter_map(|id| {
2673 let id = u32::try_from(id).ok()?;
2674 Some((id, id))
2675 })
2676 .collect(),
2677 ),
2678 };
2679 write_cache_file_from_sources(cache_dir, &plan, &mut sources)
2680 })();
2681
2682 let _ = fs::remove_dir_all(&spill_dir);
2683 let base = build_result?;
2684 let base_file_count =
2685 u32::try_from(files.len()).map_err(|_| std::io::Error::other("too many files to index"))?;
2686 let git_head = current_git_head(&project_root);
2687 let index = SearchIndex {
2688 base: Some(Arc::new(base)),
2689 delta: Arc::new(DeltaState::default()),
2690 delta_file_trigrams: HashMap::new(),
2691 files: Arc::new(files),
2692 path_to_id: Arc::new(path_to_id),
2693 ready: false,
2694 build_denied: false,
2695 project_root,
2696 git_head,
2697 max_file_size,
2698 ignore_rules_fingerprint: ignore_fingerprint,
2699 file_trigram_count: Arc::new(file_trigram_count),
2700 unindexed_files: Arc::new(unindexed_files),
2701 base_file_count,
2702 delta_packed_bytes: 0,
2703 compaction_state: Arc::new(Mutex::new(CompactionState::default())),
2704 };
2705 Ok((index, indexed))
2706}
2707
2708fn write_cache_file_from_sources(
2709 cache_dir: &Path,
2710 plan: &CacheWritePlan,
2711 sources: &mut [Box<dyn PostingRecordSource>],
2712) -> std::io::Result<BasePostings> {
2713 fs::create_dir_all(cache_dir)?;
2714 sweep_stale_search_build_dirs(cache_dir);
2715 let cache_path = cache_dir.join("cache.bin");
2716 let tmp_cache = cache_dir.join(format!(
2717 "cache.bin.tmp.{}.{}",
2718 std::process::id(),
2719 SystemTime::now()
2720 .duration_since(UNIX_EPOCH)
2721 .unwrap_or(Duration::ZERO)
2722 .as_nanos()
2723 ));
2724
2725 let write_result = (|| -> std::io::Result<BasePostings> {
2726 let raw = OpenOptions::new()
2727 .write(true)
2728 .create_new(true)
2729 .open(&tmp_cache)?;
2730 let mut writer = BufWriter::new(raw);
2731 write_u32(&mut writer, CACHE_MAGIC)?;
2732 write_u32(&mut writer, INDEX_VERSION)?;
2733 let postings_len_patch = writer.stream_position()?;
2734 write_u64(&mut writer, 0)?;
2735
2736 let postings_section_start = writer.stream_position()?;
2737 let postings_header = build_postings_header_bytes(plan)?;
2738 writer.write_all(&postings_header)?;
2739 let postings_blob_len_patch = writer.stream_position()?;
2740 write_u64(&mut writer, 0)?;
2741 let postings_blob_start = writer.stream_position()?;
2742
2743 let (lookup_entries, postings_blob_len) = merge_sources_to_writer(sources, &mut writer)?;
2744 let extension = build_file_trigram_count_extension(&plan.file_trigram_count)?;
2745 writer.write_all(&extension)?;
2746 let postings_crc_end = writer.stream_position()?;
2747
2748 writer.flush()?;
2749 writer.seek(SeekFrom::Start(postings_blob_len_patch))?;
2750 write_u64(&mut writer, postings_blob_len)?;
2751 writer.flush()?;
2752
2753 let checksum = crc32_file_range(
2754 &tmp_cache,
2755 postings_section_start,
2756 postings_crc_end.saturating_sub(postings_section_start),
2757 )?;
2758 writer.seek(SeekFrom::Start(postings_crc_end))?;
2759 writer.write_all(&checksum.to_le_bytes())?;
2760 let postings_section_end = writer.stream_position()?;
2761 let postings_len_total = postings_section_end.saturating_sub(postings_section_start);
2762 writer.seek(SeekFrom::Start(postings_len_patch))?;
2763 write_u64(&mut writer, postings_len_total)?;
2764 writer.seek(SeekFrom::Start(postings_section_end))?;
2765
2766 let lookup_blob = build_lookup_section_bytes(&lookup_entries)?;
2767 writer.write_all(&lookup_blob)?;
2768 writer.flush()?;
2769 writer.get_ref().sync_all()?;
2770 drop(writer);
2771
2772 fs::rename(&tmp_cache, &cache_path)?;
2773 sync_parent_dir(&cache_path);
2774 let file = open_cache_file_read(&cache_path)?;
2775 Ok(BasePostings {
2776 file: Arc::new(file),
2777 postings_blob_start,
2778 postings_blob_len,
2779 lookup: Arc::new(lookup_entries),
2780 })
2781 })();
2782
2783 if write_result.is_err() {
2784 let _ = fs::remove_file(&tmp_cache);
2785 }
2786 write_result
2787}
2788
2789fn merge_sources_to_writer(
2790 sources: &mut [Box<dyn PostingRecordSource>],
2791 writer: &mut BufWriter<File>,
2792) -> std::io::Result<(Vec<LookupEntry>, u64)> {
2793 let mut heap = BinaryHeap::new();
2794 for (source_index, source) in sources.iter_mut().enumerate() {
2795 if let Some(record) = source.next_record()? {
2796 heap.push(HeapItem {
2797 record,
2798 source_index,
2799 });
2800 }
2801 }
2802
2803 let mut lookup_entries = Vec::new();
2804 let mut postings_blob_len = 0u64;
2805 let mut current_trigram: Option<u32> = None;
2806 let mut current_offset = 0u64;
2807 let mut current_count = 0u32;
2808
2809 while let Some(item) = heap.pop() {
2810 let record = item.record;
2811 if current_trigram != Some(record.trigram) {
2812 if let Some(trigram) = current_trigram {
2813 lookup_entries.push(LookupEntry {
2814 trigram,
2815 offset: current_offset,
2816 count: current_count,
2817 });
2818 }
2819 current_trigram = Some(record.trigram);
2820 current_offset = postings_blob_len;
2821 current_count = 0;
2822 }
2823
2824 writer.write_all(&record.file_id.to_le_bytes())?;
2825 writer.write_all(&[record.next_mask, record.loc_mask])?;
2826 postings_blob_len = postings_blob_len
2827 .checked_add(POSTING_BYTES as u64)
2828 .ok_or_else(|| std::io::Error::other("postings blob too large"))?;
2829 current_count = current_count
2830 .checked_add(1)
2831 .ok_or_else(|| std::io::Error::other("posting list too large"))?;
2832
2833 if let Some(next) = sources[item.source_index].next_record()? {
2834 heap.push(HeapItem {
2835 record: next,
2836 source_index: item.source_index,
2837 });
2838 }
2839 }
2840
2841 if let Some(trigram) = current_trigram {
2842 lookup_entries.push(LookupEntry {
2843 trigram,
2844 offset: current_offset,
2845 count: current_count,
2846 });
2847 }
2848
2849 Ok((lookup_entries, postings_blob_len))
2850}
2851
2852fn build_postings_header_bytes(plan: &CacheWritePlan) -> std::io::Result<Vec<u8>> {
2853 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
2854 writer.write_all(INDEX_MAGIC)?;
2855 write_u32(&mut writer, INDEX_VERSION)?;
2856
2857 let head = plan.git_head.as_deref().unwrap_or_default();
2858 let root = plan.project_root.to_string_lossy();
2859 let head_len = u32::try_from(head.len())
2860 .map_err(|_| std::io::Error::other("git head too large to cache"))?;
2861 let root_len = u32::try_from(root.len())
2862 .map_err(|_| std::io::Error::other("project root too large to cache"))?;
2863 let ignore_fingerprint_len = u32::try_from(plan.ignore_fingerprint.len())
2864 .map_err(|_| std::io::Error::other("ignore fingerprint too large to cache"))?;
2865 let file_count = u32::try_from(plan.files.len())
2866 .map_err(|_| std::io::Error::other("too many files to cache"))?;
2867
2868 write_u32(&mut writer, head_len)?;
2869 write_u32(&mut writer, root_len)?;
2870 write_u32(&mut writer, ignore_fingerprint_len)?;
2871 write_u64(&mut writer, plan.max_file_size)?;
2872 write_u32(&mut writer, file_count)?;
2873 writer.write_all(head.as_bytes())?;
2874 writer.write_all(root.as_bytes())?;
2875 writer.write_all(plan.ignore_fingerprint.as_bytes())?;
2876
2877 for (file_id, file) in plan.files.iter().enumerate() {
2878 let file_id =
2879 u32::try_from(file_id).map_err(|_| std::io::Error::other("too many files to cache"))?;
2880 let path = cache_relative_path(&plan.project_root, &file.path)
2881 .or_else(|| {
2882 fs::canonicalize(&file.path)
2883 .ok()
2884 .and_then(|canonical| cache_relative_path(&plan.project_root, &canonical))
2885 })
2886 .ok_or_else(|| {
2887 std::io::Error::other(format!(
2888 "refusing to cache path outside project root: {}",
2889 file.path.display()
2890 ))
2891 })?;
2892 let path = path.to_string_lossy();
2893 let path_len = u32::try_from(path.len())
2894 .map_err(|_| std::io::Error::other("cached path too large"))?;
2895 let modified = file
2896 .modified
2897 .duration_since(UNIX_EPOCH)
2898 .unwrap_or(Duration::ZERO);
2899 let unindexed = if plan.unindexed_files.contains(&file_id) {
2900 1u8
2901 } else {
2902 0u8
2903 };
2904
2905 writer.write_all(&[unindexed])?;
2906 write_u32(&mut writer, path_len)?;
2907 write_u64(&mut writer, file.size)?;
2908 write_u64(&mut writer, modified.as_secs())?;
2909 write_u32(&mut writer, modified.subsec_nanos())?;
2910 writer.write_all(file.content_hash.as_bytes())?;
2911 writer.write_all(path.as_bytes())?;
2912 }
2913
2914 writer.flush()?;
2915 Ok(writer
2916 .into_inner()
2917 .map_err(|error| std::io::Error::other(error.to_string()))?
2918 .into_inner())
2919}
2920
2921fn build_lookup_section_bytes(lookup_entries: &[LookupEntry]) -> std::io::Result<Vec<u8>> {
2922 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
2923 let entry_count = u32::try_from(lookup_entries.len())
2924 .map_err(|_| std::io::Error::other("too many lookup entries to cache"))?;
2925 writer.write_all(LOOKUP_MAGIC)?;
2926 write_u32(&mut writer, INDEX_VERSION)?;
2927 write_u32(&mut writer, entry_count)?;
2928 for entry in lookup_entries {
2929 write_u32(&mut writer, entry.trigram)?;
2930 write_u64(&mut writer, entry.offset)?;
2931 write_u32(&mut writer, entry.count)?;
2932 }
2933 writer.flush()?;
2934 let mut lookup_blob = writer
2935 .into_inner()
2936 .map_err(|error| std::io::Error::other(error.to_string()))?
2937 .into_inner();
2938 let checksum = crc32fast::hash(&lookup_blob);
2939 lookup_blob.extend_from_slice(&checksum.to_le_bytes());
2940 Ok(lookup_blob)
2941}
2942
2943fn build_file_trigram_count_extension(counts: &[u32]) -> std::io::Result<Vec<u8>> {
2944 let mut writer = BufWriter::new(Cursor::new(Vec::new()));
2945 writer.write_all(FILE_TRIGRAM_COUNT_MAGIC)?;
2946 write_u32(&mut writer, INDEX_VERSION)?;
2947 write_u32(
2948 &mut writer,
2949 u32::try_from(counts.len())
2950 .map_err(|_| std::io::Error::other("too many file trigram counts"))?,
2951 )?;
2952 for count in counts {
2953 write_u32(&mut writer, *count)?;
2954 }
2955 writer.flush()?;
2956 Ok(writer
2957 .into_inner()
2958 .map_err(|error| std::io::Error::other(error.to_string()))?
2959 .into_inner())
2960}
2961
2962fn flush_spill_segment(
2963 spill_dir: &Path,
2964 seq: usize,
2965 block: &mut Vec<SpillRecord>,
2966) -> std::io::Result<PathBuf> {
2967 if block.is_empty() {
2968 return Err(std::io::Error::other(
2969 "refusing to write empty search spill",
2970 ));
2971 }
2972 block.sort_unstable_by_key(|record| (record.trigram, record.file_id));
2973 let path = spill_dir.join(format!("segment.{seq:06}.bin"));
2974 let mut writer = BufWriter::new(File::create(&path)?);
2975 writer.write_all(SPILL_MAGIC)?;
2976 write_u32(&mut writer, INDEX_VERSION)?;
2977 write_u64(
2978 &mut writer,
2979 u64::try_from(block.len()).map_err(|_| std::io::Error::other("search spill too large"))?,
2980 )?;
2981
2982 let mut index = 0usize;
2983 while index < block.len() {
2984 let trigram = block[index].trigram;
2985 let group_start = index;
2986 while index < block.len() && block[index].trigram == trigram {
2987 index += 1;
2988 }
2989 write_u32(&mut writer, trigram)?;
2990 write_u32(
2991 &mut writer,
2992 u32::try_from(index - group_start)
2993 .map_err(|_| std::io::Error::other("search spill group too large"))?,
2994 )?;
2995 for record in &block[group_start..index] {
2996 writer.write_all(&record.file_id.to_le_bytes())?;
2997 writer.write_all(&[record.next_mask, record.loc_mask])?;
2998 }
2999 }
3000 writer.flush()?;
3001 writer.get_ref().sync_all()?;
3002 block.clear();
3003 Ok(path)
3004}
3005
3006fn create_spill_dir(cache_dir: &Path) -> std::io::Result<PathBuf> {
3007 let dir = cache_dir.join(format!(
3008 "search-build.tmp.{}.{}",
3009 std::process::id(),
3010 SystemTime::now()
3011 .duration_since(UNIX_EPOCH)
3012 .unwrap_or(Duration::ZERO)
3013 .as_nanos()
3014 ));
3015 fs::create_dir_all(&dir)?;
3016 Ok(dir)
3017}
3018
3019fn sweep_stale_search_build_dirs(cache_dir: &Path) {
3020 let Ok(entries) = fs::read_dir(cache_dir) else {
3021 return;
3022 };
3023 for entry in entries.flatten() {
3024 let file_name = entry.file_name();
3025 if file_name.to_string_lossy().starts_with("search-build.tmp.") {
3026 let _ = fs::remove_dir_all(entry.path());
3027 }
3028 }
3029}
3030
3031fn transient_search_cache_dir(root: &Path) -> PathBuf {
3032 std::env::temp_dir().join(format!(
3033 "aft-search-cache.{}.{}.{}",
3034 artifact_cache_key(root),
3035 std::process::id(),
3036 SystemTime::now()
3037 .duration_since(UNIX_EPOCH)
3038 .unwrap_or(Duration::ZERO)
3039 .as_nanos()
3040 ))
3041}
3042
3043fn read_file_trigram_count_extension(
3044 base: &BasePostings,
3045 extension_start: u64,
3046 postings_body_end: u64,
3047 file_count: usize,
3048) -> std::io::Result<Option<Vec<u32>>> {
3049 if extension_start >= postings_body_end {
3050 return Ok(None);
3051 }
3052 let extension_len = postings_body_end - extension_start;
3053 if extension_len < 16 {
3054 return Ok(None);
3055 }
3056 let mut header = [0u8; 16];
3057 pread_exact(&base.file, extension_start, &mut header)?;
3058 if &header[..8] != FILE_TRIGRAM_COUNT_MAGIC {
3059 return Ok(None);
3060 }
3061 let version = u32::from_le_bytes([header[8], header[9], header[10], header[11]]);
3062 if version != INDEX_VERSION {
3063 return Err(std::io::Error::other("invalid file trigram count version"));
3064 }
3065 let count = u32::from_le_bytes([header[12], header[13], header[14], header[15]]) as usize;
3066 if count != file_count {
3067 return Err(std::io::Error::other("file trigram count length mismatch"));
3068 }
3069 let counts_len = count
3070 .checked_mul(4)
3071 .ok_or_else(|| std::io::Error::other("file trigram count extension too large"))?;
3072 if 16u64 + counts_len as u64 > extension_len {
3073 return Err(std::io::Error::other(
3074 "truncated file trigram count extension",
3075 ));
3076 }
3077 let mut bytes = vec![0u8; counts_len];
3078 pread_exact(&base.file, extension_start + 16, &mut bytes)?;
3079 let mut counts = Vec::with_capacity(count);
3080 for chunk in bytes.chunks_exact(4) {
3081 counts.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
3082 }
3083 Ok(Some(counts))
3084}
3085
3086fn compute_file_trigram_counts_from_base(
3087 base: &BasePostings,
3088 file_count: usize,
3089) -> std::io::Result<Vec<u32>> {
3090 let mut counts = vec![0u32; file_count];
3091 for entry in base.lookup.iter().copied() {
3092 for posting in base.read_postings(entry)? {
3093 let Some(count) = counts.get_mut(posting.file_id as usize) else {
3094 return Err(std::io::Error::other("posting references missing file"));
3095 };
3096 *count = count.saturating_add(1);
3097 }
3098 }
3099 Ok(counts)
3100}
3101
3102fn ensure_count_slot(counts: &mut Vec<u32>, file_id: u32) {
3103 let len = file_id as usize + 1;
3104 if counts.len() < len {
3105 counts.resize(len, 0);
3106 }
3107}
3108
3109fn reader_has_remaining<R: Seek>(
3110 reader: &mut R,
3111 absolute_end: u64,
3112 len: usize,
3113) -> std::io::Result<bool> {
3114 let position = reader.stream_position()?;
3115 Ok(position <= absolute_end && (len as u64) <= absolute_end - position)
3116}
3117
3118fn crc32_file_range(path: &Path, start: u64, len: u64) -> std::io::Result<u32> {
3119 let mut file = File::open(path)?;
3120 file.seek(SeekFrom::Start(start))?;
3121 let mut hasher = crc32fast::Hasher::new();
3122 let mut remaining = len;
3123 let mut buffer = vec![0u8; 1024 * 1024];
3124 while remaining > 0 {
3125 let read_len = buffer.len().min(remaining as usize);
3126 let bytes_read = file.read(&mut buffer[..read_len])?;
3127 if bytes_read == 0 {
3128 return Err(std::io::Error::new(
3129 std::io::ErrorKind::UnexpectedEof,
3130 "truncated cache while checksumming",
3131 ));
3132 }
3133 hasher.update(&buffer[..bytes_read]);
3134 remaining -= bytes_read as u64;
3135 }
3136 Ok(hasher.finalize())
3137}
3138
3139fn sync_parent_dir(path: &Path) {
3140 if let Some(parent) = path.parent() {
3141 if let Ok(dir) = File::open(parent) {
3142 let _ = dir.sync_all();
3143 }
3144 }
3145}
3146
3147fn open_cache_file_read(path: &Path) -> std::io::Result<File> {
3148 let mut options = OpenOptions::new();
3149 options.read(true);
3150 #[cfg(windows)]
3151 {
3152 use std::os::windows::fs::OpenOptionsExt;
3153 const FILE_SHARE_READ: u32 = 0x0000_0001;
3154 const FILE_SHARE_WRITE: u32 = 0x0000_0002;
3155 const FILE_SHARE_DELETE: u32 = 0x0000_0004;
3156 options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
3157 }
3158 options.open(path)
3159}
3160
3161#[cfg(unix)]
3162fn pread_exact(file: &File, mut offset: u64, mut buffer: &mut [u8]) -> std::io::Result<()> {
3163 use std::os::unix::fs::FileExt;
3164 while !buffer.is_empty() {
3165 let bytes_read = file.read_at(buffer, offset)?;
3166 if bytes_read == 0 {
3167 return Err(std::io::Error::new(
3168 std::io::ErrorKind::UnexpectedEof,
3169 "short pread from search cache",
3170 ));
3171 }
3172 offset += bytes_read as u64;
3173 let (_, rest) = buffer.split_at_mut(bytes_read);
3174 buffer = rest;
3175 }
3176 Ok(())
3177}
3178
3179#[cfg(windows)]
3180fn pread_exact(file: &File, mut offset: u64, mut buffer: &mut [u8]) -> std::io::Result<()> {
3181 use std::os::windows::fs::FileExt;
3182 while !buffer.is_empty() {
3183 let bytes_read = file.seek_read(buffer, offset)?;
3184 if bytes_read == 0 {
3185 return Err(std::io::Error::new(
3186 std::io::ErrorKind::UnexpectedEof,
3187 "short pread from search cache",
3188 ));
3189 }
3190 offset += bytes_read as u64;
3191 let (_, rest) = buffer.split_at_mut(bytes_read);
3192 buffer = rest;
3193 }
3194 Ok(())
3195}
3196
3197fn intersect_sorted_ids(left: &[u32], right: &[u32]) -> Vec<u32> {
3198 let mut merged = Vec::with_capacity(left.len().min(right.len()));
3199 let mut left_index = 0;
3200 let mut right_index = 0;
3201
3202 while left_index < left.len() && right_index < right.len() {
3203 match left[left_index].cmp(&right[right_index]) {
3204 std::cmp::Ordering::Less => left_index += 1,
3205 std::cmp::Ordering::Greater => right_index += 1,
3206 std::cmp::Ordering::Equal => {
3207 merged.push(left[left_index]);
3208 left_index += 1;
3209 right_index += 1;
3210 }
3211 }
3212 }
3213
3214 merged
3215}
3216
3217fn union_sorted_ids(left: &[u32], right: &[u32]) -> Vec<u32> {
3218 let mut merged = Vec::with_capacity(left.len() + right.len());
3219 let mut left_index = 0;
3220 let mut right_index = 0;
3221
3222 while left_index < left.len() && right_index < right.len() {
3223 match left[left_index].cmp(&right[right_index]) {
3224 std::cmp::Ordering::Less => {
3225 merged.push(left[left_index]);
3226 left_index += 1;
3227 }
3228 std::cmp::Ordering::Greater => {
3229 merged.push(right[right_index]);
3230 right_index += 1;
3231 }
3232 std::cmp::Ordering::Equal => {
3233 merged.push(left[left_index]);
3234 left_index += 1;
3235 right_index += 1;
3236 }
3237 }
3238 }
3239
3240 merged.extend_from_slice(&left[left_index..]);
3241 merged.extend_from_slice(&right[right_index..]);
3242 merged
3243}
3244
3245pub fn decompose_regex(pattern: &str) -> RegexQuery {
3246 let hir = match regex_syntax::parse(pattern) {
3247 Ok(hir) => hir,
3248 Err(_) => return RegexQuery::default(),
3249 };
3250
3251 let build = build_query(&hir);
3252 build.into_query()
3253}
3254
3255pub fn pack_trigram(a: u8, b: u8, c: u8) -> u32 {
3256 ((a as u32) << 16) | ((b as u32) << 8) | c as u32
3257}
3258
3259pub fn normalize_char(c: u8) -> u8 {
3260 c.to_ascii_lowercase()
3261}
3262
3263fn scan_trigrams(content: &[u8], mut visit: impl FnMut(u32, u8, usize)) {
3264 if content.len() < 3 {
3265 return;
3266 }
3267
3268 for start in 0..=content.len() - 3 {
3269 let trigram = pack_trigram(
3270 normalize_char(content[start]),
3271 normalize_char(content[start + 1]),
3272 normalize_char(content[start + 2]),
3273 );
3274 let next_char = content.get(start + 3).copied().unwrap_or(EOF_SENTINEL);
3275 visit(trigram, next_char, start);
3276 }
3277}
3278
3279pub fn extract_trigrams(content: &[u8]) -> Vec<(u32, u8, usize)> {
3280 let mut trigrams = Vec::with_capacity(content.len().saturating_sub(2));
3281 scan_trigrams(content, |trigram, next_char, position| {
3282 trigrams.push((trigram, next_char, position));
3283 });
3284 trigrams
3285}
3286
3287fn trigram_filter_map(content: &[u8], include_eof_next_char: bool) -> BTreeMap<u32, PostingFilter> {
3288 let mut filters: BTreeMap<u32, PostingFilter> = BTreeMap::new();
3289 scan_trigrams(content, |trigram, next_char, position| {
3290 let entry = filters.entry(trigram).or_default();
3291 if include_eof_next_char || next_char != EOF_SENTINEL {
3292 entry.next_mask |= mask_for_next_char(next_char);
3293 }
3294 entry.loc_mask |= mask_for_position(position);
3295 });
3296 filters
3297}
3298
3299pub fn query_trigrams_from_tokens(tokens: &[&str]) -> Vec<u32> {
3300 let mut seen = HashSet::new();
3301 let mut out = Vec::new();
3302 for token in tokens {
3303 scan_trigrams(token.as_bytes(), |trigram, _, _| {
3304 if seen.insert(trigram) {
3305 out.push(trigram);
3306 }
3307 });
3308 }
3309 out
3310}
3311
3312pub fn lexical_score(index: &SearchIndex, query_trigrams: &[u32], file_id: u32) -> f32 {
3313 lexical_score_snapshot(&index.snapshot(), query_trigrams, file_id)
3314}
3315
3316fn materialize_query_postings(
3317 index: &SearchIndexSnapshot,
3318 query_trigrams: &[u32],
3319) -> HashMap<u32, Vec<u32>> {
3320 let mut postings_by_trigram = HashMap::with_capacity(query_trigrams.len());
3321 for &trigram in query_trigrams {
3322 postings_by_trigram
3323 .entry(trigram)
3324 .or_insert_with(|| index.postings_for_trigram(trigram, None));
3325 }
3326 postings_by_trigram
3327}
3328
3329fn lexical_score_snapshot(
3330 index: &SearchIndexSnapshot,
3331 query_trigrams: &[u32],
3332 file_id: u32,
3333) -> f32 {
3334 let postings_by_trigram = materialize_query_postings(index, query_trigrams);
3335 lexical_score_from_postings(index, query_trigrams, &postings_by_trigram, file_id)
3336}
3337
3338fn lexical_score_from_postings(
3339 index: &SearchIndexSnapshot,
3340 query_trigrams: &[u32],
3341 postings_by_trigram: &HashMap<u32, Vec<u32>>,
3342 file_id: u32,
3343) -> f32 {
3344 if query_trigrams.is_empty() {
3345 return 0.0;
3346 }
3347
3348 let mut hits = 0u32;
3349 for &trigram in query_trigrams {
3350 if postings_by_trigram
3351 .get(&trigram)
3352 .is_some_and(|postings| postings.binary_search(&file_id).is_ok())
3353 {
3354 hits += 1;
3355 }
3356 }
3357
3358 if hits == 0 {
3359 return 0.0;
3360 }
3361
3362 let file_trigram_count = index
3363 .file_trigram_count
3364 .get(file_id as usize)
3365 .copied()
3366 .unwrap_or(1)
3367 .max(1) as f32;
3368 (hits as f32) / (1.0 + file_trigram_count.ln())
3369}
3370
3371#[cfg(test)]
3372fn lexical_rank_with_stats_reference(
3373 index: &SearchIndexSnapshot,
3374 query_trigrams: &[u32],
3375 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
3376 max_files: usize,
3377) -> LexicalRankResult {
3378 if query_trigrams.is_empty() || max_files == 0 {
3379 return LexicalRankResult::default();
3380 }
3381
3382 let mut non_zero: Vec<(u32, usize)> = query_trigrams
3383 .iter()
3384 .filter_map(|trigram| {
3385 let posting_count = index.posting_count(*trigram);
3386 (posting_count > 0).then_some((*trigram, posting_count))
3387 })
3388 .collect();
3389 if non_zero.is_empty() {
3390 return LexicalRankResult::default();
3391 }
3392
3393 non_zero.sort_unstable_by_key(|(_, posting_count)| *posting_count);
3394 let selected_count = non_zero.len().min(3);
3395 let candidate_cap = if selected_count == 3 { 200 } else { 500 };
3396
3397 let mut candidate_ids = BTreeSet::new();
3398 for (trigram, _) in non_zero.iter().take(selected_count) {
3399 candidate_ids.extend(index.postings_for_trigram(*trigram, None));
3400 }
3401 let pre_filter_candidate_count = candidate_ids.len();
3402 let engine_capped = pre_filter_candidate_count > candidate_cap;
3403 let filtered_candidates = candidate_ids
3404 .into_iter()
3405 .filter_map(|file_id| {
3406 index
3407 .files
3408 .get(file_id as usize)
3409 .map(|entry| (file_id, entry))
3410 })
3411 .filter(|(_, entry)| {
3412 candidate_filter
3413 .map(|filter| filter(&entry.path))
3414 .unwrap_or(true)
3415 })
3416 .collect::<Vec<_>>();
3417
3418 let mut ranked = Vec::new();
3419 for (file_id, entry) in filtered_candidates.into_iter().take(candidate_cap) {
3420 let score = lexical_score_snapshot_reference(index, query_trigrams, file_id);
3421 if score > 0.0 {
3422 ranked.push((entry.path.clone(), score));
3423 }
3424 }
3425
3426 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3427 ranked.truncate(max_files);
3428 LexicalRankResult {
3429 files: ranked,
3430 engine_capped,
3431 }
3432}
3433
3434#[cfg(test)]
3435fn lexical_score_snapshot_reference(
3436 index: &SearchIndexSnapshot,
3437 query_trigrams: &[u32],
3438 file_id: u32,
3439) -> f32 {
3440 if query_trigrams.is_empty() {
3441 return 0.0;
3442 }
3443
3444 let mut hits = 0u32;
3445 for &trigram in query_trigrams {
3446 let postings = index.postings_for_trigram(trigram, None);
3447 if postings.binary_search(&file_id).is_ok() {
3448 hits += 1;
3449 }
3450 }
3451
3452 if hits == 0 {
3453 return 0.0;
3454 }
3455
3456 let file_trigram_count = index
3457 .file_trigram_count
3458 .get(file_id as usize)
3459 .copied()
3460 .unwrap_or(1)
3461 .max(1) as f32;
3462 (hits as f32) / (1.0 + file_trigram_count.ln())
3463}
3464
3465pub fn resolve_cache_dir(project_root: &Path, storage_dir: Option<&Path>) -> PathBuf {
3466 resolve_cache_dir_with_key(&artifact_cache_key(project_root), storage_dir)
3467}
3468
3469pub(crate) fn build_path_filters(
3470 include: &[String],
3471 exclude: &[String],
3472) -> Result<PathFilters, String> {
3473 Ok(PathFilters {
3474 includes: build_globset(include)?,
3475 excludes: build_globset(exclude)?,
3476 })
3477}
3478
3479pub(crate) fn walk_project_files(root: &Path, filters: &PathFilters) -> Vec<PathBuf> {
3480 walk_project_files_from(root, root, filters)
3481}
3482
3483pub fn walk_project_files_bounded_default(
3484 root: &Path,
3485 max_files: usize,
3486) -> Result<Vec<PathBuf>, usize> {
3487 walk_project_files_from_inner(root, root, &PathFilters::default(), Some(max_files), true)
3488}
3489
3490pub(crate) fn walk_project_files_bounded_matching<F>(
3491 root: &Path,
3492 filters: &PathFilters,
3493 max_files: usize,
3494 matches_file: F,
3495) -> Result<Vec<PathBuf>, usize>
3496where
3497 F: Fn(&Path) -> bool,
3498{
3499 walk_project_files_from_inner_matching(root, root, filters, Some(max_files), matches_file, true)
3500}
3501
3502pub fn walk_project_files_bounded_default_matching<F>(
3503 root: &Path,
3504 max_files: usize,
3505 matches_file: F,
3506) -> Result<Vec<PathBuf>, usize>
3507where
3508 F: Fn(&Path) -> bool,
3509{
3510 walk_project_files_from_inner_matching(
3511 root,
3512 root,
3513 &PathFilters::default(),
3514 Some(max_files),
3515 matches_file,
3516 true,
3517 )
3518}
3519
3520pub(crate) fn walk_project_files_from(
3521 filter_root: &Path,
3522 search_root: &Path,
3523 filters: &PathFilters,
3524) -> Vec<PathBuf> {
3525 walk_project_files_from_inner(filter_root, search_root, filters, None, true)
3526 .expect("unbounded project walk cannot exceed a file limit")
3527}
3528
3529pub(crate) fn has_any_project_file_from(
3530 filter_root: &Path,
3531 search_root: &Path,
3532 filters: &PathFilters,
3533) -> bool {
3534 walk_project_files_from_inner(filter_root, search_root, filters, Some(0), true).is_err()
3535}
3536
3537fn walk_project_files_from_inner(
3538 filter_root: &Path,
3539 search_root: &Path,
3540 filters: &PathFilters,
3541 max_files: Option<usize>,
3542 sort_by_mtime: bool,
3543) -> Result<Vec<PathBuf>, usize> {
3544 walk_project_files_from_inner_matching(
3545 filter_root,
3546 search_root,
3547 filters,
3548 max_files,
3549 |_| true,
3550 sort_by_mtime,
3551 )
3552}
3553
3554fn project_walk_builder(search_root: &Path) -> WalkBuilder {
3555 let mut builder = WalkBuilder::new(search_root);
3556 builder
3557 .hidden(false)
3558 .git_ignore(true)
3559 .git_global(true)
3560 .git_exclude(true)
3561 .add_custom_ignore_filename(".aftignore")
3562 .filter_entry(|entry| {
3563 let name = entry.file_name().to_string_lossy();
3564 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3565 return !matches!(
3566 name.as_ref(),
3567 "node_modules"
3568 | "target"
3569 | "venv"
3570 | ".venv"
3571 | ".git"
3572 | "__pycache__"
3573 | ".tox"
3574 | "dist"
3575 | "build"
3576 );
3577 }
3578 true
3579 });
3580 builder
3581}
3582
3583fn walk_project_files_from_inner_matching<F>(
3584 filter_root: &Path,
3585 search_root: &Path,
3586 filters: &PathFilters,
3587 max_files: Option<usize>,
3588 matches_file: F,
3589 sort_by_mtime: bool,
3590) -> Result<Vec<PathBuf>, usize>
3591where
3592 F: Fn(&Path) -> bool,
3593{
3594 let builder = project_walk_builder(search_root);
3595
3596 let mut files = Vec::new();
3597 for entry in builder.build().filter_map(|entry| entry.ok()) {
3598 if !entry
3599 .file_type()
3600 .map_or(false, |file_type| file_type.is_file())
3601 {
3602 continue;
3603 }
3604 let path = entry.into_path();
3605 if filters.matches(filter_root, &path) && matches_file(&path) {
3606 files.push(path);
3607 if max_files.is_some_and(|limit| files.len() > limit) {
3608 return Err(files.len());
3609 }
3610 }
3611 }
3612
3613 if sort_by_mtime {
3614 sort_paths_by_mtime_desc(&mut files);
3615 }
3616 Ok(files)
3617}
3618
3619pub(crate) fn read_searchable_text(path: &Path) -> Option<String> {
3620 let bytes = fs::read(path).ok()?;
3621 if is_binary_bytes(&bytes) {
3622 return None;
3623 }
3624 String::from_utf8(bytes).ok()
3625}
3626
3627fn read_indexed_file_bytes(path: &Path) -> Option<Vec<u8>> {
3628 fs::read(path).ok()
3629}
3630
3631pub(crate) fn relative_to_root(root: &Path, path: &Path) -> PathBuf {
3632 path.strip_prefix(root)
3633 .map(PathBuf::from)
3634 .unwrap_or_else(|_| path.to_path_buf())
3635}
3636
3637pub(crate) fn cache_relative_path(root: &Path, path: &Path) -> Option<PathBuf> {
3638 let normalized_root = normalize_path(root);
3639 let normalized_path = normalize_path(path);
3640 let relative = normalized_path.strip_prefix(&normalized_root).ok()?;
3641 validate_cached_relative_path(relative)
3642}
3643
3644pub(crate) fn cached_path_under_root(root: &Path, relative_path: &Path) -> Option<PathBuf> {
3645 let relative = validate_cached_relative_path(relative_path)?;
3646 let normalized_root = normalize_path(root);
3647 let full_path = normalize_path(&normalized_root.join(relative));
3648
3649 match fs::canonicalize(&full_path) {
3650 Ok(canonical_path) => {
3651 if is_within_search_root(&normalized_root, &canonical_path) {
3655 return Some(full_path);
3656 }
3657
3658 let canonical_root = fs::canonicalize(&normalized_root).ok()?;
3659 is_within_search_root(&canonical_root, &canonical_path).then_some(full_path)
3660 }
3661 Err(_) => is_within_search_root(&normalized_root, &full_path).then_some(full_path),
3662 }
3663}
3664
3665pub(crate) fn validate_cached_relative_path(path: &Path) -> Option<PathBuf> {
3666 if path.is_absolute() {
3667 return None;
3668 }
3669
3670 let mut normalized = PathBuf::new();
3671 for component in path.components() {
3672 match component {
3673 Component::Normal(part) => normalized.push(part),
3674 Component::CurDir => {}
3675 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
3676 }
3677 }
3678 (!normalized.as_os_str().is_empty()).then_some(normalized)
3679}
3680
3681pub(crate) fn sort_paths_by_mtime_desc(paths: &mut [PathBuf]) {
3694 use std::collections::HashMap;
3695 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::with_capacity(paths.len());
3696 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(paths.len());
3697 for path in paths.iter() {
3698 mtimes
3699 .entry(path.clone())
3700 .or_insert_with(|| path_modified_time(path));
3701 display_paths
3702 .entry(path.clone())
3703 .or_insert_with(|| normalized_display_sort_key(None, path));
3704 }
3705 paths.sort_by(|left, right| {
3706 let left_mtime = mtimes.get(left).and_then(|v| *v);
3707 let right_mtime = mtimes.get(right).and_then(|v| *v);
3708 let left_display = display_paths
3709 .get(left)
3710 .map(String::as_bytes)
3711 .unwrap_or_default();
3712 let right_display = display_paths
3713 .get(right)
3714 .map(String::as_bytes)
3715 .unwrap_or_default();
3716 right_mtime
3717 .cmp(&left_mtime)
3718 .then_with(|| left_display.cmp(right_display))
3719 });
3720}
3721
3722pub(crate) fn sort_grep_matches_by_mtime_desc(matches: &mut [GrepMatch], project_root: &Path) {
3725 use std::collections::HashMap;
3726 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::new();
3727 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(matches.len());
3728 for m in matches.iter() {
3729 mtimes.entry(m.file.clone()).or_insert_with(|| {
3730 let resolved = resolve_match_path(project_root, &m.file);
3731 path_modified_time(&resolved)
3732 });
3733 display_paths
3734 .entry(m.file.clone())
3735 .or_insert_with(|| normalized_display_sort_key(Some(project_root), &m.file));
3736 }
3737 matches.sort_by(|left, right| {
3738 let left_mtime = mtimes.get(&left.file).and_then(|v| *v);
3739 let right_mtime = mtimes.get(&right.file).and_then(|v| *v);
3740 let left_display = display_paths
3741 .get(&left.file)
3742 .map(String::as_bytes)
3743 .unwrap_or_default();
3744 let right_display = display_paths
3745 .get(&right.file)
3746 .map(String::as_bytes)
3747 .unwrap_or_default();
3748 right_mtime
3752 .cmp(&left_mtime)
3753 .then_with(|| left_display.cmp(right_display))
3754 .then_with(|| left.line.cmp(&right.line))
3755 .then_with(|| left.column.cmp(&right.column))
3756 });
3757}
3758
3759fn sort_shared_grep_matches_by_cached_mtime_desc<F>(
3764 matches: &mut [SharedGrepMatch],
3765 project_root: &Path,
3766 modified_for_path: F,
3767) where
3768 F: Fn(&Path) -> Option<SystemTime>,
3769{
3770 use std::collections::HashMap;
3771 let mut mtimes: HashMap<PathBuf, Option<SystemTime>> = HashMap::with_capacity(matches.len());
3772 let mut display_paths: HashMap<PathBuf, String> = HashMap::with_capacity(matches.len());
3773 for m in matches.iter() {
3774 let path = m.file.as_path().to_path_buf();
3775 mtimes
3776 .entry(path.clone())
3777 .or_insert_with(|| modified_for_path(&path));
3778 display_paths
3779 .entry(path.clone())
3780 .or_insert_with(|| normalized_display_sort_key(Some(project_root), &path));
3781 }
3782 matches.sort_by(|left, right| {
3783 let left_mtime = mtimes.get(left.file.as_path()).and_then(|v| *v);
3784 let right_mtime = mtimes.get(right.file.as_path()).and_then(|v| *v);
3785 let left_display = display_paths
3786 .get(left.file.as_path())
3787 .map(String::as_bytes)
3788 .unwrap_or_default();
3789 let right_display = display_paths
3790 .get(right.file.as_path())
3791 .map(String::as_bytes)
3792 .unwrap_or_default();
3793 right_mtime
3797 .cmp(&left_mtime)
3798 .then_with(|| left_display.cmp(right_display))
3799 .then_with(|| left.line.cmp(&right.line))
3800 .then_with(|| left.column.cmp(&right.column))
3801 });
3802}
3803
3804pub(crate) fn resolve_search_scope(project_root: &Path, path: Option<&str>) -> SearchScope {
3805 let resolved_project_root = canonicalize_or_normalize(project_root);
3809 let root = match path {
3810 Some(path) => {
3811 let path = PathBuf::from(path);
3812 if path.is_absolute() {
3813 canonicalize_or_normalize(&path)
3814 } else {
3815 normalize_path(&resolved_project_root.join(path))
3816 }
3817 }
3818 None => resolved_project_root.clone(),
3819 };
3820
3821 let use_index = is_within_search_root(&resolved_project_root, &root);
3822 SearchScope { root, use_index }
3823}
3824
3825pub(crate) fn is_binary_bytes(content: &[u8]) -> bool {
3826 content_inspector::inspect(content).is_binary()
3827}
3828
3829pub(crate) fn current_git_head(root: &Path) -> Option<String> {
3830 run_git(root, &["rev-parse", "HEAD"])
3831}
3832
3833#[derive(Clone, Debug, PartialEq, Eq)]
3834pub struct ArtifactCacheKeyProbeError {
3835 root: PathBuf,
3836 detail: String,
3837}
3838
3839impl ArtifactCacheKeyProbeError {
3840 pub fn root(&self) -> &Path {
3841 &self.root
3842 }
3843
3844 pub fn detail(&self) -> &str {
3845 &self.detail
3846 }
3847}
3848
3849impl std::fmt::Display for ArtifactCacheKeyProbeError {
3850 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3851 write!(
3852 formatter,
3853 "artifact cache key probe failed for {}: {}",
3854 self.root.display(),
3855 self.detail
3856 )
3857 }
3858}
3859
3860impl std::error::Error for ArtifactCacheKeyProbeError {}
3861
3862pub fn artifact_cache_key(project_root: &Path) -> String {
3863 let key = match repo_root_commit_with_retry(project_root) {
3864 RootCommitResolution::Commit(root_commit) => artifact_key_from_git_identity(&root_commit),
3865 RootCommitResolution::NotARepo => artifact_key_from_path_identity(project_root),
3866 RootCommitResolution::Failed(detail) => {
3867 crate::slog_warn!(
3868 "artifact cache key: git root-commit probe failed after retries ({}); \
3869 falling back to path identity for {}",
3870 detail,
3871 project_root.display()
3872 );
3873 artifact_key_from_path_identity(project_root)
3874 }
3875 };
3876 record_derived_cache_key(project_root, &key);
3877 key
3878}
3879
3880pub fn artifact_cache_key_with_memo(
3881 probe_root: &Path,
3882 memo_root: &Path,
3883 storage_root: &Path,
3884 git_common_dir: Option<&Path>,
3885) -> Result<String, ArtifactCacheKeyProbeError> {
3886 let memo_root_key = artifact_cache_key_memo_root_key(memo_root);
3887 let git_marker_state = root_git_marker_state(probe_root, git_common_dir);
3888 if git_marker_state == GitMarkerState::Absent {
3889 return Ok(artifact_key_from_path_identity(probe_root));
3890 }
3891
3892 match repo_root_commit_with_retry(probe_root) {
3893 RootCommitResolution::Commit(root_commit) => {
3894 let key = artifact_key_from_git_identity(&root_commit);
3895 record_derived_cache_key(memo_root, &key);
3896 if let Err(error) =
3897 record_artifact_cache_key_memo(storage_root, &memo_root_key, &key, &root_commit)
3898 {
3899 crate::slog_warn!(
3900 "artifact cache key: failed to persist memo for {} in {}: {}",
3901 memo_root.display(),
3902 storage_root.display(),
3903 error
3904 );
3905 }
3906 Ok(key)
3907 }
3908 RootCommitResolution::NotARepo => Ok(artifact_key_from_path_identity(probe_root)),
3909 RootCommitResolution::Failed(detail) => {
3910 if let Some(entry) = lookup_artifact_cache_key_memo(storage_root, &memo_root_key) {
3911 crate::slog_warn!(
3912 "artifact cache key: probe failed, using memoized key {} for {}",
3913 entry.key,
3914 memo_root.display()
3915 );
3916 return Ok(entry.key);
3917 }
3918
3919 match git_marker_state {
3920 GitMarkerState::Absent => Ok(artifact_key_from_path_identity(probe_root)),
3921 GitMarkerState::Present => Err(ArtifactCacheKeyProbeError {
3922 root: memo_root.to_path_buf(),
3923 detail,
3924 }),
3925 GitMarkerState::Unknown(marker_detail) => Err(ArtifactCacheKeyProbeError {
3926 root: memo_root.to_path_buf(),
3927 detail: format!("{detail}; {marker_detail}"),
3928 }),
3929 }
3930 }
3931 }
3932}
3933
3934static DERIVED_CACHE_KEYS: OnceLock<RwLock<HashMap<PathBuf, String>>> = OnceLock::new();
3939
3940fn record_derived_cache_key(project_root: &Path, key: &str) {
3941 let map = DERIVED_CACHE_KEYS.get_or_init(|| RwLock::new(HashMap::new()));
3942 if let Ok(mut map) = map.write() {
3943 map.insert(project_root.to_path_buf(), key.to_string());
3944 }
3945}
3946
3947pub fn artifact_cache_key_memoized_only(project_root: &Path) -> Option<String> {
3960 let map = DERIVED_CACHE_KEYS.get()?;
3961 let map = map.try_read().ok()?;
3962 map.get(project_root).cloned()
3963}
3964
3965pub fn resolve_cache_dir_with_key(project_key: &str, storage_dir: Option<&Path>) -> PathBuf {
3966 if let Some(override_dir) = std::env::var_os("AFT_CACHE_DIR") {
3967 return PathBuf::from(override_dir).join("index").join(project_key);
3968 }
3969 if let Some(dir) = storage_dir {
3970 return dir.join("index").join(project_key);
3971 }
3972 crate::bash_background::storage_dir(None)
3973 .join("index")
3974 .join(project_key)
3975}
3976
3977fn artifact_key_from_git_identity(root_commit: &str) -> String {
3978 artifact_hash16(root_commit.as_bytes())
3979}
3980
3981fn artifact_key_from_path_identity(project_root: &Path) -> String {
3982 let canonical_root = canonicalize_or_normalize(project_root);
3983 artifact_hash16(canonical_root.to_string_lossy().as_bytes())
3984}
3985
3986#[cfg(test)]
3987pub(crate) fn artifact_path_identity_key_for_test(project_root: &Path) -> String {
3988 artifact_key_from_path_identity(project_root)
3989}
3990
3991fn artifact_hash16(bytes: &[u8]) -> String {
3992 use sha2::{Digest, Sha256};
3993
3994 let mut hasher = Sha256::new();
3995 hasher.update(bytes);
3996 let digest = format!("{:x}", hasher.finalize());
3997 digest[..16].to_string()
3998}
3999
4000fn artifact_cache_key_memo_root_key(root: &Path) -> String {
4001 root.to_string_lossy().into_owned()
4002}
4003
4004fn artifact_cache_key_memo_path(storage_root: &Path) -> PathBuf {
4005 storage_root.join(ARTIFACT_CACHE_KEY_MEMO_FILE)
4006}
4007
4008fn artifact_cache_key_memo_state() -> &'static Mutex<ArtifactCacheKeyMemoState> {
4009 ARTIFACT_CACHE_KEY_MEMO_STATE.get_or_init(|| Mutex::new(ArtifactCacheKeyMemoState::default()))
4010}
4011
4012impl ArtifactCacheKeyMemoState {
4013 fn entries_for_storage_root(
4014 &mut self,
4015 storage_root: &Path,
4016 ) -> &mut BTreeMap<String, ArtifactCacheKeyMemoEntry> {
4017 if !self.by_storage_root.contains_key(storage_root) {
4018 let entries = read_artifact_cache_key_memo_file(storage_root);
4019 self.by_storage_root
4020 .insert(storage_root.to_path_buf(), entries);
4021 }
4022 self.by_storage_root
4023 .get_mut(storage_root)
4024 .expect("memo storage root inserted")
4025 }
4026}
4027
4028fn lookup_artifact_cache_key_memo(
4029 storage_root: &Path,
4030 memo_root_key: &str,
4031) -> Option<ArtifactCacheKeyMemoEntry> {
4032 let mut state = artifact_cache_key_memo_state()
4033 .lock()
4034 .unwrap_or_else(std::sync::PoisonError::into_inner);
4035 state
4036 .entries_for_storage_root(storage_root)
4037 .get(memo_root_key)
4038 .cloned()
4039}
4040
4041fn record_artifact_cache_key_memo(
4042 storage_root: &Path,
4043 memo_root_key: &str,
4044 key: &str,
4045 git_root_commit: &str,
4046) -> std::io::Result<()> {
4047 let mut state = artifact_cache_key_memo_state()
4048 .lock()
4049 .unwrap_or_else(std::sync::PoisonError::into_inner);
4050 let entries = state.entries_for_storage_root(storage_root);
4051 if entries
4052 .get(memo_root_key)
4053 .is_some_and(|entry| entry.key == key && entry.git_root_commit == git_root_commit)
4054 {
4055 return Ok(());
4056 }
4057 entries.insert(
4058 memo_root_key.to_string(),
4059 ArtifactCacheKeyMemoEntry {
4060 key: key.to_string(),
4061 git_root_commit: git_root_commit.to_string(),
4062 recorded_at_ms: current_time_millis(),
4063 },
4064 );
4065 write_artifact_cache_key_memo_file(storage_root, entries)
4066}
4067
4068fn read_artifact_cache_key_memo_file(
4069 storage_root: &Path,
4070) -> BTreeMap<String, ArtifactCacheKeyMemoEntry> {
4071 let path = artifact_cache_key_memo_path(storage_root);
4072 let bytes = match fs::read(&path) {
4073 Ok(bytes) => bytes,
4074 Err(_) => return BTreeMap::new(),
4075 };
4076 let entries =
4077 match serde_json::from_slice::<BTreeMap<String, ArtifactCacheKeyMemoEntry>>(&bytes) {
4078 Ok(entries) => entries,
4079 Err(error) => {
4080 crate::slog_warn!(
4081 "artifact cache key: ignoring corrupt memo file {}: {}",
4082 path.display(),
4083 error
4084 );
4085 return BTreeMap::new();
4086 }
4087 };
4088 entries
4089 .into_iter()
4090 .filter(|(root, entry)| {
4091 !root.is_empty()
4092 && artifact_key_looks_valid(&entry.key)
4093 && !entry.git_root_commit.trim().is_empty()
4094 })
4095 .collect()
4096}
4097
4098fn write_artifact_cache_key_memo_file(
4099 storage_root: &Path,
4100 entries: &BTreeMap<String, ArtifactCacheKeyMemoEntry>,
4101) -> std::io::Result<()> {
4102 fs::create_dir_all(storage_root)?;
4103 let path = artifact_cache_key_memo_path(storage_root);
4104 let temp_path = storage_root.join(format!(
4105 ".{ARTIFACT_CACHE_KEY_MEMO_FILE}.tmp.{}.{}",
4106 std::process::id(),
4107 SystemTime::now()
4108 .duration_since(UNIX_EPOCH)
4109 .unwrap_or(Duration::ZERO)
4110 .as_nanos()
4111 ));
4112 let bytes = serde_json::to_vec_pretty(entries).map_err(std::io::Error::other)?;
4113 {
4114 let mut file = File::create(&temp_path)?;
4115 file.write_all(&bytes)?;
4116 }
4117 if let Err(error) = fs::rename(&temp_path, &path) {
4118 let _ = fs::remove_file(&temp_path);
4119 return Err(error);
4120 }
4121 Ok(())
4122}
4123
4124fn artifact_key_looks_valid(key: &str) -> bool {
4125 key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
4126}
4127
4128fn current_time_millis() -> u64 {
4129 SystemTime::now()
4130 .duration_since(UNIX_EPOCH)
4131 .unwrap_or(Duration::ZERO)
4132 .as_millis()
4133 .min(u128::from(u64::MAX)) as u64
4134}
4135
4136#[derive(Debug, PartialEq, Eq)]
4137enum GitMarkerState {
4138 Present,
4139 Absent,
4140 Unknown(String),
4141}
4142
4143fn root_git_marker_state(project_root: &Path, git_common_dir: Option<&Path>) -> GitMarkerState {
4144 if git_common_dir.is_some() {
4145 return GitMarkerState::Present;
4146 }
4147 let git_marker = project_root.join(".git");
4148 match fs::symlink_metadata(&git_marker) {
4149 Ok(_) => GitMarkerState::Present,
4150 Err(error) if error.kind() == std::io::ErrorKind::NotFound => GitMarkerState::Absent,
4151 Err(error) => GitMarkerState::Unknown(format!(
4152 "failed to inspect git marker {}: {}",
4153 git_marker.display(),
4154 error
4155 )),
4156 }
4157}
4158
4159fn repo_root_commit_with_retry(project_root: &Path) -> RootCommitResolution {
4168 for attempt in 0..3u32 {
4169 match git_root_commit_once(project_root) {
4170 RootCommitProbe::Commit(commit) => return RootCommitResolution::Commit(commit),
4171 RootCommitProbe::NotARepo => return RootCommitResolution::NotARepo,
4172 RootCommitProbe::NoCommit => return RootCommitResolution::NotARepo,
4173 RootCommitProbe::Transient(detail) => {
4174 if attempt == 2 {
4175 return RootCommitResolution::Failed(detail);
4176 }
4177 std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1)));
4178 }
4179 }
4180 }
4181 RootCommitResolution::Failed("git root-commit probe retry loop exhausted".to_string())
4182}
4183
4184enum RootCommitResolution {
4185 Commit(String),
4186 NotARepo,
4187 Failed(String),
4188}
4189
4190enum RootCommitProbe {
4191 Commit(String),
4192 NotARepo,
4194 NoCommit,
4196 Transient(String),
4198}
4199
4200fn git_root_commit_once(project_root: &Path) -> RootCommitProbe {
4201 #[cfg(test)]
4202 if let Some(override_probe) = GIT_ROOT_COMMIT_PROBE_OVERRIDE
4203 .get_or_init(|| Mutex::new(None))
4204 .lock()
4205 .unwrap_or_else(std::sync::PoisonError::into_inner)
4206 .clone()
4207 {
4208 if let Some(result) = override_probe(project_root) {
4209 return result;
4210 }
4211 }
4212
4213 git_root_commit_once_real(project_root)
4214}
4215
4216fn canonicalize_root_commit_output(stdout: &[u8]) -> RootCommitProbe {
4220 let decoded = String::from_utf8_lossy(stdout);
4221 let mut roots: Vec<&str> = decoded
4222 .lines()
4223 .map(str::trim)
4224 .filter(|line| !line.is_empty())
4225 .collect();
4226 roots.sort_unstable();
4227 roots.dedup();
4228
4229 if roots.is_empty() {
4230 RootCommitProbe::NoCommit
4231 } else {
4232 RootCommitProbe::Commit(roots.join("\n"))
4233 }
4234}
4235
4236fn git_root_commit_once_real(project_root: &Path) -> RootCommitProbe {
4237 let output = match crate::effective_path::new_command("git")
4238 .arg("-C")
4239 .arg(project_root)
4240 .args(["rev-list", "--max-parents=0", "HEAD"])
4241 .output()
4242 {
4243 Ok(output) => output,
4244 Err(error) => return RootCommitProbe::Transient(format!("spawn failed: {error}")),
4245 };
4246
4247 if output.status.success() {
4248 return canonicalize_root_commit_output(&output.stdout);
4249 }
4250
4251 let stderr = String::from_utf8_lossy(&output.stderr);
4252 if stderr.contains("not a git repository") {
4253 return RootCommitProbe::NotARepo;
4254 }
4255 if stderr.contains("unknown revision")
4256 || stderr.contains("bad revision")
4257 || stderr.contains("ambiguous argument 'HEAD'")
4258 {
4259 return RootCommitProbe::NoCommit;
4260 }
4261 RootCommitProbe::Transient(format!(
4262 "exit {:?}: {}",
4263 output.status.code(),
4264 stderr.trim().chars().take(200).collect::<String>()
4265 ))
4266}
4267
4268#[cfg(test)]
4269pub(crate) struct GitRootCommitProbeOverrideGuard {
4270 previous: Option<RootCommitProbeOverride>,
4271}
4272
4273#[cfg(test)]
4274impl Drop for GitRootCommitProbeOverrideGuard {
4275 fn drop(&mut self) {
4276 set_git_root_commit_probe_override_for_test(self.previous.take());
4277 }
4278}
4279
4280#[cfg(test)]
4281pub(crate) fn git_root_commit_probe_override_lock_for_test() -> std::sync::MutexGuard<'static, ()> {
4282 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4283 LOCK.get_or_init(|| Mutex::new(()))
4284 .lock()
4285 .unwrap_or_else(std::sync::PoisonError::into_inner)
4286}
4287
4288#[cfg(test)]
4289pub(crate) fn force_git_root_commit_probe_transient_for_paths_for_test(
4290 roots: Vec<PathBuf>,
4291 detail: impl Into<String>,
4292) -> GitRootCommitProbeOverrideGuard {
4293 let detail = Arc::new(detail.into());
4294 install_git_root_commit_probe_override_for_test(move |project_root| {
4295 roots
4296 .iter()
4297 .any(|root| root == project_root)
4298 .then(|| RootCommitProbe::Transient((*detail).clone()))
4299 })
4300}
4301
4302#[cfg(test)]
4303pub(crate) fn force_git_root_commit_probe_commits_for_test(
4304 commits_by_root: BTreeMap<PathBuf, String>,
4305) -> GitRootCommitProbeOverrideGuard {
4306 install_git_root_commit_probe_override_for_test(move |project_root| {
4307 commits_by_root
4308 .get(project_root)
4309 .cloned()
4310 .map(RootCommitProbe::Commit)
4311 })
4312}
4313
4314#[cfg(test)]
4315fn install_git_root_commit_probe_override_for_test(
4316 override_probe: impl Fn(&Path) -> Option<RootCommitProbe> + Send + Sync + 'static,
4317) -> GitRootCommitProbeOverrideGuard {
4318 let previous = set_git_root_commit_probe_override_for_test(Some(Arc::new(override_probe)));
4319 GitRootCommitProbeOverrideGuard { previous }
4320}
4321
4322#[cfg(test)]
4323fn set_git_root_commit_probe_override_for_test(
4324 override_probe: Option<RootCommitProbeOverride>,
4325) -> Option<RootCommitProbeOverride> {
4326 let mut slot = GIT_ROOT_COMMIT_PROBE_OVERRIDE
4327 .get_or_init(|| Mutex::new(None))
4328 .lock()
4329 .unwrap_or_else(std::sync::PoisonError::into_inner);
4330 std::mem::replace(&mut *slot, override_probe)
4331}
4332
4333pub fn ignore_rules_fingerprint(project_root: &Path) -> String {
4341 use sha2::{Digest, Sha256};
4342
4343 let root = canonicalize_or_normalize(project_root);
4344 let mut files = Vec::new();
4345 collect_ignore_rule_files(&root, &mut files);
4346 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
4347 if global_ignore.is_file() {
4348 files.push(global_ignore);
4349 }
4350 }
4351 let info_exclude = git_info_exclude_path(&root);
4352 if info_exclude.is_file() {
4353 files.push(info_exclude);
4354 }
4355 files.sort();
4356 files.dedup();
4357
4358 let mut hasher = Sha256::new();
4359 hasher.update(b"aft-ignore-rules-v1\0");
4360 for path in files {
4361 if let Some(relative) = cache_relative_path(&root, &path) {
4362 hasher.update(relative.to_string_lossy().as_bytes());
4363 } else {
4364 hasher.update(path.to_string_lossy().as_bytes());
4365 }
4366 hasher.update(b"\0");
4367 match fs::read(&path) {
4368 Ok(bytes) => hasher.update(&bytes),
4369 Err(error) => hasher.update(format!("read-error:{error}").as_bytes()),
4370 }
4371 hasher.update(b"\0");
4372 }
4373
4374 format!("{:x}", hasher.finalize())
4375}
4376
4377fn git_info_exclude_path(root: &Path) -> PathBuf {
4378 run_git(
4379 root,
4380 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
4381 )
4382 .map(PathBuf::from)
4383 .unwrap_or_else(|| root.join(".git"))
4384 .join("info")
4385 .join("exclude")
4386}
4387
4388fn collect_ignore_rule_files(root: &Path, files: &mut Vec<PathBuf>) {
4389 let mut builder = WalkBuilder::new(root);
4390 builder
4391 .hidden(false)
4392 .git_ignore(true)
4393 .git_global(true)
4394 .git_exclude(true)
4395 .add_custom_ignore_filename(".aftignore")
4396 .filter_entry(|entry| {
4397 let name = entry.file_name().to_string_lossy();
4398 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4399 return !matches!(
4400 name.as_ref(),
4401 ".git"
4402 | "node_modules"
4403 | "target"
4404 | "venv"
4405 | ".venv"
4406 | "__pycache__"
4407 | ".tox"
4408 | "dist"
4409 | "build"
4410 );
4411 }
4412 true
4413 });
4414
4415 for entry in builder.build().filter_map(|entry| entry.ok()) {
4416 if !entry
4417 .file_type()
4418 .map_or(false, |file_type| file_type.is_file())
4419 {
4420 continue;
4421 }
4422 let file_name = entry.file_name();
4423 if file_name == ".gitignore" || file_name == ".aftignore" {
4424 files.push(entry.into_path());
4425 }
4426 }
4427}
4428
4429#[cfg(test)]
4431pub(crate) fn count_ignore_rule_discovery_dirs(root: &Path) -> usize {
4432 let mut dirs = 0usize;
4433 let mut builder = WalkBuilder::new(root);
4434 builder
4435 .hidden(false)
4436 .git_ignore(true)
4437 .git_global(true)
4438 .git_exclude(true)
4439 .add_custom_ignore_filename(".aftignore");
4440 for entry in builder.build().filter_map(|entry| entry.ok()) {
4441 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4442 dirs += 1;
4443 }
4444 }
4445 dirs
4446}
4447
4448#[cfg(test)]
4450pub(crate) fn count_ignore_rule_discovery_dirs_legacy_stack(root: &Path) -> usize {
4451 let mut stack = vec![root.to_path_buf()];
4452 let mut dirs = 0usize;
4453 while let Some(dir) = stack.pop() {
4454 dirs += 1;
4455 let Ok(entries) = fs::read_dir(&dir) else {
4456 continue;
4457 };
4458 for entry in entries.flatten() {
4459 let path = entry.path();
4460 let file_name = entry.file_name();
4461 if file_name == ".gitignore" || file_name == ".aftignore" {
4462 continue;
4463 }
4464 let Ok(file_type) = entry.file_type() else {
4465 continue;
4466 };
4467 if !file_type.is_dir() || file_type.is_symlink() {
4468 continue;
4469 }
4470 if matches!(
4471 file_name.to_str().unwrap_or(""),
4472 ".git"
4473 | "node_modules"
4474 | "target"
4475 | "venv"
4476 | ".venv"
4477 | "__pycache__"
4478 | ".tox"
4479 | "dist"
4480 | "build"
4481 ) {
4482 continue;
4483 }
4484 stack.push(path);
4485 }
4486 }
4487 dirs
4488}
4489
4490impl PathFilters {
4491 pub(crate) fn matches(&self, root: &Path, path: &Path) -> bool {
4492 let relative = to_glob_path(&relative_to_root(root, path));
4493 if self
4494 .includes
4495 .as_ref()
4496 .is_some_and(|includes| !includes.is_match(&relative))
4497 {
4498 return false;
4499 }
4500 if self
4501 .excludes
4502 .as_ref()
4503 .is_some_and(|excludes| excludes.is_match(&relative))
4504 {
4505 return false;
4506 }
4507 true
4508 }
4509}
4510
4511fn canonicalize_for_search_membership(path: &Path) -> PathBuf {
4512 crate::inspect::job::canonicalize_normalized(path)
4517}
4518
4519fn canonicalize_or_normalize(path: &Path) -> PathBuf {
4520 fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
4521}
4522
4523fn resolve_match_path(project_root: &Path, path: &Path) -> PathBuf {
4524 if path.is_absolute() {
4525 path.to_path_buf()
4526 } else {
4527 project_root.join(path)
4528 }
4529}
4530
4531fn path_modified_time(path: &Path) -> Option<SystemTime> {
4532 fs::metadata(path)
4533 .and_then(|metadata| metadata.modified())
4534 .ok()
4535}
4536
4537fn normalized_display_sort_key(project_root: Option<&Path>, path: &Path) -> String {
4538 let display_path = project_root
4539 .and_then(|root| path.strip_prefix(root).ok())
4540 .unwrap_or(path);
4541 to_glob_path(display_path)
4542}
4543
4544fn normalize_path(path: &Path) -> PathBuf {
4545 let mut result = PathBuf::new();
4546 for component in path.components() {
4547 match component {
4548 Component::ParentDir => {
4549 if !result.pop() {
4550 result.push(component);
4551 }
4552 }
4553 Component::CurDir => {}
4554 _ => result.push(component),
4555 }
4556 }
4557 result
4558}
4559
4560fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
4561 if let Ok(canonical) = fs::canonicalize(path) {
4562 return canonical;
4563 }
4564
4565 let Some(parent) = path.parent() else {
4566 return path.to_path_buf();
4567 };
4568 let Some(file_name) = path.file_name() else {
4569 return path.to_path_buf();
4570 };
4571
4572 fs::canonicalize(parent)
4573 .map(|canonical_parent| canonical_parent.join(file_name))
4574 .unwrap_or_else(|_| path.to_path_buf())
4575}
4576
4577fn verify_file_mtimes(
4580 index: &mut SearchIndex,
4581 verify_strategy: cache_freshness::VerifyStrategy,
4582) -> bool {
4583 let filters = PathFilters::default();
4584 let current_files = walk_project_files(&index.project_root, &filters);
4585 let current_file_set: HashSet<PathBuf> = current_files.iter().cloned().collect();
4586 let mut stale_paths = Vec::new();
4587 let mut removed_paths = Vec::new();
4588 let mut changed = false;
4589
4590 for entry in Arc::make_mut(&mut index.files).iter_mut() {
4591 if entry.path.as_os_str().is_empty() {
4592 continue; }
4594 if !current_file_set.contains(&entry.path) {
4595 removed_paths.push(entry.path.clone());
4596 continue;
4597 }
4598 let cached = FileFreshness {
4599 mtime: entry.modified,
4600 size: entry.size,
4601 content_hash: entry.content_hash,
4602 };
4603 let verdict = match verify_strategy {
4604 cache_freshness::VerifyStrategy::StatFirst => {
4605 cache_freshness::verify_file(&entry.path, &cached)
4606 }
4607 cache_freshness::VerifyStrategy::Strict => {
4608 cache_freshness::verify_file_strict(&entry.path, &cached)
4609 }
4610 };
4611 match verdict {
4612 FreshnessVerdict::HotFresh => {}
4613 FreshnessVerdict::ContentFresh {
4614 new_mtime,
4615 new_size,
4616 } => {
4617 entry.modified = new_mtime;
4618 entry.size = new_size;
4619 changed = true;
4620 }
4621 FreshnessVerdict::Stale | FreshnessVerdict::Deleted => {
4622 stale_paths.push(entry.path.clone())
4623 }
4624 }
4625 }
4626
4627 for path in &removed_paths {
4628 index.remove_file(path);
4629 changed = true;
4630 }
4631
4632 for path in &stale_paths {
4636 if current_file_set.contains(path) {
4637 index.update_file(path);
4638 } else {
4639 index.remove_file(path);
4640 }
4641 changed = true;
4642 }
4643
4644 for path in current_files {
4646 if !index.path_to_id.contains_key(&path) {
4647 index.update_file(&path);
4648 changed = true;
4649 }
4650 }
4651
4652 if !stale_paths.is_empty() {
4653 crate::slog_info!(
4654 "search index: refreshed {} stale file(s) from disk cache",
4655 stale_paths.len()
4656 );
4657 }
4658 changed
4659}
4660
4661fn is_within_search_root(search_root: &Path, path: &Path) -> bool {
4662 crate::inspect::job::normalize_path(path)
4663 .starts_with(crate::inspect::job::normalize_path(search_root))
4664}
4665
4666impl QueryBuild {
4667 fn into_query(self) -> RegexQuery {
4668 let mut query = RegexQuery::default();
4669
4670 for run in self.and_runs {
4671 add_run_to_and_query(&mut query, &run);
4672 }
4673
4674 for group in self.or_groups {
4675 let mut trigrams = BTreeSet::new();
4676 let mut filters = HashMap::new();
4677 for run in group {
4678 for (trigram, filter) in trigram_filters(&run) {
4679 trigrams.insert(trigram);
4680 merge_filter(filters.entry(trigram).or_default(), filter);
4681 }
4682 }
4683 if !trigrams.is_empty() {
4684 query.or_groups.push(trigrams.into_iter().collect());
4685 query.or_filters.push(filters);
4686 }
4687 }
4688
4689 query
4690 }
4691}
4692
4693fn build_query(hir: &Hir) -> QueryBuild {
4694 match hir.kind() {
4695 HirKind::Literal(literal) => {
4696 if literal.0.len() >= 3 {
4697 QueryBuild {
4698 and_runs: vec![literal.0.to_vec()],
4699 or_groups: Vec::new(),
4700 }
4701 } else {
4702 QueryBuild::default()
4703 }
4704 }
4705 HirKind::Capture(capture) => build_query(&capture.sub),
4706 HirKind::Concat(parts) => {
4707 let mut build = QueryBuild::default();
4708 for part in parts {
4709 let part_build = build_query(part);
4710 build.and_runs.extend(part_build.and_runs);
4711 build.or_groups.extend(part_build.or_groups);
4712 }
4713 build
4714 }
4715 HirKind::Alternation(parts) => {
4716 let mut group = Vec::new();
4717 for part in parts {
4718 let Some(mut choices) = guaranteed_run_choices(part) else {
4719 return QueryBuild::default();
4720 };
4721 group.append(&mut choices);
4722 }
4723 if group.is_empty() {
4724 QueryBuild::default()
4725 } else {
4726 QueryBuild {
4727 and_runs: Vec::new(),
4728 or_groups: vec![group],
4729 }
4730 }
4731 }
4732 HirKind::Repetition(repetition) => {
4733 if repetition.min == 0 {
4734 QueryBuild::default()
4735 } else {
4736 build_query(&repetition.sub)
4737 }
4738 }
4739 HirKind::Empty | HirKind::Class(_) | HirKind::Look(_) => QueryBuild::default(),
4740 }
4741}
4742
4743fn guaranteed_run_choices(hir: &Hir) -> Option<Vec<Vec<u8>>> {
4744 match hir.kind() {
4745 HirKind::Literal(literal) => {
4746 if literal.0.len() >= 3 {
4747 Some(vec![literal.0.to_vec()])
4748 } else {
4749 None
4750 }
4751 }
4752 HirKind::Capture(capture) => guaranteed_run_choices(&capture.sub),
4753 HirKind::Concat(parts) => {
4754 let mut runs = Vec::new();
4755 for part in parts {
4756 if let Some(mut part_runs) = guaranteed_run_choices(part) {
4757 runs.append(&mut part_runs);
4758 }
4759 }
4760 if runs.is_empty() {
4761 None
4762 } else {
4763 Some(runs)
4764 }
4765 }
4766 HirKind::Alternation(parts) => {
4767 let mut runs = Vec::new();
4768 for part in parts {
4769 let Some(mut part_runs) = guaranteed_run_choices(part) else {
4770 return None;
4771 };
4772 runs.append(&mut part_runs);
4773 }
4774 if runs.is_empty() {
4775 None
4776 } else {
4777 Some(runs)
4778 }
4779 }
4780 HirKind::Repetition(repetition) => {
4781 if repetition.min == 0 {
4782 None
4783 } else {
4784 guaranteed_run_choices(&repetition.sub)
4785 }
4786 }
4787 HirKind::Empty | HirKind::Class(_) | HirKind::Look(_) => None,
4788 }
4789}
4790
4791fn add_run_to_and_query(query: &mut RegexQuery, run: &[u8]) {
4792 for (trigram, filter) in trigram_filters(run) {
4793 if !query.and_trigrams.contains(&trigram) {
4794 query.and_trigrams.push(trigram);
4795 }
4796 merge_filter(query.and_filters.entry(trigram).or_default(), filter);
4797 }
4798}
4799
4800fn trigram_filters(run: &[u8]) -> Vec<(u32, PostingFilter)> {
4801 trigram_filter_map(run, false).into_iter().collect()
4802}
4803
4804fn merge_filter(target: &mut PostingFilter, filter: PostingFilter) {
4805 target.next_mask |= filter.next_mask;
4806 target.loc_mask |= filter.loc_mask;
4807}
4808
4809fn mask_for_next_char(next_char: u8) -> u8 {
4810 let bit = (normalize_char(next_char).wrapping_mul(31) & 7) as u32;
4811 1u8 << bit
4812}
4813
4814fn mask_for_position(position: usize) -> u8 {
4815 1u8 << (position % 8)
4816}
4817
4818fn build_globset(patterns: &[String]) -> Result<Option<GlobSet>, String> {
4819 if patterns.is_empty() {
4820 return Ok(None);
4821 }
4822
4823 let mut builder = GlobSetBuilder::new();
4824 for pattern in patterns {
4825 let glob = Glob::new(pattern).map_err(|error| error.to_string())?;
4826 builder.add(glob);
4827 }
4828 builder.build().map(Some).map_err(|error| error.to_string())
4829}
4830
4831fn read_u32<R: Read>(reader: &mut R) -> std::io::Result<u32> {
4832 let mut buffer = [0u8; 4];
4833 reader.read_exact(&mut buffer)?;
4834 Ok(u32::from_le_bytes(buffer))
4835}
4836
4837fn read_u64<R: Read>(reader: &mut R) -> std::io::Result<u64> {
4838 let mut buffer = [0u8; 8];
4839 reader.read_exact(&mut buffer)?;
4840 Ok(u64::from_le_bytes(buffer))
4841}
4842
4843fn write_u32<W: Write>(writer: &mut W, value: u32) -> std::io::Result<()> {
4844 writer.write_all(&value.to_le_bytes())
4845}
4846
4847fn write_u64<W: Write>(writer: &mut W, value: u64) -> std::io::Result<()> {
4848 writer.write_all(&value.to_le_bytes())
4849}
4850
4851fn verify_crc32_bytes_slice(bytes: &[u8]) -> std::io::Result<()> {
4852 let Some((body, stored)) = bytes.split_last_chunk::<4>() else {
4853 return Err(std::io::Error::other("search index checksum missing"));
4854 };
4855 let expected = u32::from_le_bytes(*stored);
4856 let actual = crc32fast::hash(body);
4857 if actual != expected {
4858 return Err(std::io::Error::other("search index checksum mismatch"));
4859 }
4860 Ok(())
4861}
4862
4863fn remaining_bytes<R: Seek>(reader: &mut R, total_len: usize) -> Option<usize> {
4864 let pos = usize::try_from(reader.stream_position().ok()?).ok()?;
4865 total_len.checked_sub(pos)
4866}
4867
4868fn run_git(root: &Path, args: &[&str]) -> Option<String> {
4869 const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
4870
4871 let mut child = crate::effective_path::new_command("git")
4872 .arg("-C")
4873 .arg(root)
4874 .args(args)
4875 .stdin(std::process::Stdio::null())
4876 .stdout(std::process::Stdio::piped())
4877 .stderr(std::process::Stdio::null())
4878 .spawn()
4879 .ok()?;
4880 let deadline = Instant::now() + GIT_PROBE_TIMEOUT;
4881 let status = loop {
4882 match child.try_wait() {
4883 Ok(Some(status)) => break status,
4884 Ok(None) if Instant::now() >= deadline => {
4885 let _ = child.kill();
4886 let _ = child.wait();
4887 return None;
4888 }
4889 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
4890 Err(_) => {
4891 let _ = child.kill();
4892 let _ = child.wait();
4893 return None;
4894 }
4895 }
4896 };
4897 if !status.success() {
4898 return None;
4899 }
4900 let mut stdout = Vec::new();
4901 child.stdout.take()?.read_to_end(&mut stdout).ok()?;
4902 let value = String::from_utf8(stdout).ok()?;
4903 let value = value.trim().to_string();
4904 (!value.is_empty()).then_some(value)
4905}
4906
4907fn apply_git_diff_updates(index: &mut SearchIndex, root: &Path, from: &str, to: &str) -> bool {
4908 let diff_range = format!("{}..{}", from, to);
4909 let output = match crate::effective_path::new_command("git")
4910 .arg("-C")
4911 .arg(root)
4912 .args(["diff", "--name-status", "-M", &diff_range])
4913 .output()
4914 {
4915 Ok(output) => output,
4916 Err(_) => return false,
4917 };
4918
4919 if !output.status.success() {
4920 return false;
4921 }
4922
4923 let Ok(diff) = String::from_utf8(output.stdout) else {
4924 return false;
4925 };
4926
4927 for line in diff.lines().map(str::trim).filter(|line| !line.is_empty()) {
4928 let mut fields = line.split('\t');
4929 let Some(status) = fields.next() else {
4930 continue;
4931 };
4932
4933 if status.starts_with('R') {
4934 let Some(old_path) = fields
4935 .next()
4936 .and_then(|path| cached_path_under_root(root, &PathBuf::from(path)))
4937 else {
4938 continue;
4939 };
4940 let Some(new_path) = fields
4941 .next()
4942 .and_then(|path| cached_path_under_root(root, &PathBuf::from(path)))
4943 else {
4944 continue;
4945 };
4946 index.remove_file(&old_path);
4947 index.update_file(&new_path);
4948 continue;
4949 }
4950
4951 let Some(path) = fields
4952 .next()
4953 .and_then(|path| cached_path_under_root(root, &PathBuf::from(path)))
4954 else {
4955 continue;
4956 };
4957 if status.starts_with('D') || !path.exists() {
4958 index.remove_file(&path);
4959 } else {
4960 index.update_file(&path);
4961 }
4962 }
4963
4964 true
4965}
4966
4967fn is_binary_path(path: &Path, size: u64) -> bool {
4968 if size == 0 {
4969 return false;
4970 }
4971
4972 let mut file = match File::open(path) {
4973 Ok(file) => file,
4974 Err(_) => return true,
4975 };
4976
4977 let mut preview = vec![0u8; PREVIEW_BYTES.min(size as usize)];
4978 match file.read(&mut preview) {
4979 Ok(read) => is_binary_bytes(&preview[..read]),
4980 Err(_) => true,
4981 }
4982}
4983
4984fn line_starts_bytes(content: &[u8]) -> Vec<usize> {
4985 let mut starts = vec![0usize];
4986 for (index, byte) in content.iter().copied().enumerate() {
4987 if byte == b'\n' {
4988 starts.push(index + 1);
4989 }
4990 }
4991 starts
4992}
4993
4994fn line_details_bytes(content: &[u8], line_starts: &[usize], offset: usize) -> (u32, u32, String) {
4995 let line_index = match line_starts.binary_search(&offset) {
4996 Ok(index) => index,
4997 Err(index) => index.saturating_sub(1),
4998 };
4999 let line_start = line_starts.get(line_index).copied().unwrap_or(0);
5000 let line_end = content[line_start..]
5001 .iter()
5002 .position(|byte| *byte == b'\n')
5003 .map(|length| line_start + length)
5004 .unwrap_or(content.len());
5005 let mut line_slice = &content[line_start..line_end];
5006 if line_slice.ends_with(b"\r") {
5007 line_slice = &line_slice[..line_slice.len() - 1];
5008 }
5009 let line_text = String::from_utf8_lossy(line_slice).into_owned();
5010 let column = String::from_utf8_lossy(&content[line_start..offset])
5011 .chars()
5012 .count() as u32
5013 + 1;
5014 (line_index as u32 + 1, column, line_text)
5015}
5016
5017fn to_glob_path(path: &Path) -> String {
5018 path.to_string_lossy().replace('\\', "/")
5019}
5020
5021#[cfg(test)]
5022mod tests {
5023 use std::process::Command;
5024
5025 use super::*;
5026
5027 fn lexical_rank_mixed_storage_fixture() -> (tempfile::TempDir, SearchIndex) {
5028 let dir = tempfile::tempdir().expect("create temp dir");
5029 let project = dir.path().join("project");
5030 fs::create_dir_all(&project).expect("create project dir");
5031 for index in 0..205 {
5032 fs::write(
5033 project.join(format!("file_{index:03}.txt")),
5034 format!("abcdefghij sharedalpha marker_{index}"),
5035 )
5036 .expect("write base fixture file");
5037 }
5038
5039 let cache_dir = dir.path().join("cache");
5040 let mut built = SearchIndex::build(&project);
5041 assert!(built.write_to_disk(&cache_dir, None));
5042 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base index");
5043
5044 let replaced = project.join("file_000.txt");
5045 index.remove_file(&replaced);
5046 index.index_file(&replaced, b"abcdefghij sharedalpha replacement_delta");
5047
5048 let removed = project.join("file_001.txt");
5049 index.remove_file(&removed);
5050
5051 let added = project.join("added_delta.txt");
5052 fs::write(&added, "abcdefghij sharedalpha added_delta").expect("write delta file");
5053 index.index_file(&added, b"abcdefghij sharedalpha added_delta");
5054
5055 assert!(index.base.is_some());
5056 assert!(!index.delta.postings.is_empty());
5057 assert!(!index.delta.superseded.is_empty());
5058 (dir, index)
5059 }
5060
5061 fn postings_for_trigram_materialized_reference(
5062 index: &SearchIndexSnapshot,
5063 trigram: u32,
5064 filter: Option<PostingFilter>,
5065 ) -> Vec<u32> {
5066 let mut matches = Vec::new();
5067 if let Some(base_entry) = index
5068 .base
5069 .as_ref()
5070 .and_then(|base| base.lookup_entry(trigram))
5071 {
5072 if let Some(base) = &index.base {
5073 if let Ok(postings) = base.read_postings(base_entry) {
5074 matches.reserve(postings.len());
5075 for posting in postings {
5076 if index.delta.superseded.contains(&posting.file_id) {
5077 continue;
5078 }
5079 if !posting_matches_filter(&posting, filter) {
5080 continue;
5081 }
5082 if index.is_active_file(posting.file_id) {
5083 matches.push(posting.file_id);
5084 }
5085 }
5086 }
5087 }
5088 }
5089 if let Some(postings) = index.delta.postings.get(&trigram) {
5090 matches.reserve(postings.len());
5091 for posting in postings {
5092 if !posting_matches_filter(posting, filter) {
5093 continue;
5094 }
5095 if index.is_active_file(posting.file_id) {
5096 matches.push(posting.file_id);
5097 }
5098 }
5099 }
5100 if matches.len() > 1 {
5101 matches.sort_unstable();
5102 matches.dedup();
5103 }
5104 matches
5105 }
5106
5107 fn assert_rank_matches_reference(
5108 index: &SearchIndex,
5109 query_trigrams: &[u32],
5110 candidate_filter: Option<&dyn Fn(&Path) -> bool>,
5111 max_files: usize,
5112 ) -> LexicalRankResult {
5113 let snapshot = index.snapshot();
5114 let expected = lexical_rank_with_stats_reference(
5115 &snapshot,
5116 query_trigrams,
5117 candidate_filter,
5118 max_files,
5119 );
5120 let actual = snapshot.lexical_rank_with_stats(query_trigrams, candidate_filter, max_files);
5121 assert_eq!(actual.files, expected.files);
5122 assert_eq!(actual.engine_capped, expected.engine_capped);
5123 actual
5124 }
5125
5126 #[test]
5127 fn cached_path_under_root_allows_missing_lexical_child() {
5128 let dir = tempfile::tempdir().expect("create temp dir");
5129 let project = dir.path().join("project");
5130 fs::create_dir_all(&project).expect("create project dir");
5131 let root = fs::canonicalize(&project).expect("canonicalize project");
5132
5133 let path = cached_path_under_root(&root, Path::new("future/file.rs"))
5134 .expect("missing child should fall back to lexical validation");
5135
5136 assert_eq!(path, root.join("future/file.rs"));
5137 }
5138
5139 #[cfg(unix)]
5140 #[test]
5141 fn cached_path_under_root_rejects_symlink_escape() {
5142 let dir = tempfile::tempdir().expect("create temp dir");
5143 let project = dir.path().join("project");
5144 let outside = dir.path().join("outside");
5145 fs::create_dir_all(&project).expect("create project dir");
5146 fs::create_dir_all(&outside).expect("create outside dir");
5147 fs::write(outside.join("secret.txt"), "secret").expect("write outside file");
5148 std::os::unix::fs::symlink(&outside, project.join("link")).expect("create symlink");
5149 let root = fs::canonicalize(&project).expect("canonicalize project");
5150
5151 assert!(cached_path_under_root(&root, Path::new("link/secret.txt")).is_none());
5152 }
5153
5154 #[test]
5155 fn trigram_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
5156 let mut index = SearchIndex::new();
5157 assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
5158 index.index_file(Path::new("memory-estimate.rs"), b"fn memory_estimate() {}");
5159 let estimate = index.estimated_memory();
5160 assert!(estimate.estimated_bytes.unwrap() > 0);
5161 assert!(estimate.counts["delta_postings"] > 0);
5162 assert_eq!(estimate.counts["base_postings_resident_bytes"], 0);
5163 }
5164
5165 #[test]
5166 fn extract_trigrams_tracks_next_char_and_position() {
5167 let trigrams = extract_trigrams(b"Rust");
5168 assert_eq!(trigrams.len(), 2);
5169 assert_eq!(trigrams[0], (pack_trigram(b'r', b'u', b's'), b't', 0));
5170 assert_eq!(
5171 trigrams[1],
5172 (pack_trigram(b'u', b's', b't'), EOF_SENTINEL, 1)
5173 );
5174 }
5175
5176 #[test]
5177 fn index_file_trigram_filters_match_legacy_extraction() {
5178 let dir = tempfile::tempdir().expect("create temp dir");
5179 let path = dir.path().join("sample.txt");
5180 let content = b"Rust rust RUST\nxy";
5181 fs::write(&path, content).expect("write sample");
5182
5183 let mut expected = BTreeMap::new();
5184 for (trigram, next_char, position) in extract_trigrams(content) {
5185 let entry: &mut PostingFilter = expected.entry(trigram).or_default();
5186 entry.next_mask |= mask_for_next_char(next_char);
5187 entry.loc_mask |= mask_for_position(position);
5188 }
5189
5190 let mut index = SearchIndex::new();
5191 index.project_root = dir.path().to_path_buf();
5192 index.index_file(&path, content);
5193
5194 let file_id = *index.path_to_id.get(&path).expect("file indexed");
5195 let file_trigrams = index
5196 .delta_file_trigrams
5197 .get(&file_id)
5198 .expect("delta file trigrams");
5199 assert_eq!(file_trigrams, &expected.keys().copied().collect::<Vec<_>>());
5200 for (trigram, filter) in expected {
5201 let postings = index
5202 .delta
5203 .postings
5204 .get(&trigram)
5205 .expect("delta posting list");
5206 assert_eq!(postings.len(), 1);
5207 assert_eq!(postings[0].file_id, file_id);
5208 assert_eq!(postings[0].next_mask, filter.next_mask);
5209 assert_eq!(postings[0].loc_mask, filter.loc_mask);
5210 }
5211 }
5212
5213 #[test]
5214 fn decompose_regex_extracts_literals_and_alternations() {
5215 let query = decompose_regex("abc(def|ghi)xyz");
5216 assert!(query.and_trigrams.contains(&pack_trigram(b'a', b'b', b'c')));
5217 assert!(query.and_trigrams.contains(&pack_trigram(b'x', b'y', b'z')));
5218 assert_eq!(query.or_groups.len(), 1);
5219 assert!(query.or_groups[0].contains(&pack_trigram(b'd', b'e', b'f')));
5220 assert!(query.or_groups[0].contains(&pack_trigram(b'g', b'h', b'i')));
5221 }
5222
5223 #[test]
5224 fn candidates_intersect_posting_lists() {
5225 let mut index = SearchIndex::new();
5226 let dir = tempfile::tempdir().expect("create temp dir");
5227 let alpha = dir.path().join("alpha.txt");
5228 let beta = dir.path().join("beta.txt");
5229 fs::write(&alpha, "abcdef").expect("write alpha");
5230 fs::write(&beta, "abcxyz").expect("write beta");
5231 index.project_root = dir.path().to_path_buf();
5232 index.index_file(&alpha, b"abcdef");
5233 index.index_file(&beta, b"abcxyz");
5234
5235 let query = RegexQuery {
5236 and_trigrams: vec![
5237 pack_trigram(b'a', b'b', b'c'),
5238 pack_trigram(b'd', b'e', b'f'),
5239 ],
5240 ..RegexQuery::default()
5241 };
5242
5243 let candidates = index.candidates(&query);
5244 assert_eq!(candidates.len(), 1);
5245 assert_eq!(index.files[candidates[0] as usize].path, alpha);
5246 }
5247
5248 #[test]
5249 fn candidates_apply_bloom_filters() {
5250 let mut index = SearchIndex::new();
5251 let dir = tempfile::tempdir().expect("create temp dir");
5252 let file = dir.path().join("sample.txt");
5253 fs::write(&file, "abcd efgh").expect("write sample");
5254 index.project_root = dir.path().to_path_buf();
5255 index.index_file(&file, b"abcd efgh");
5256
5257 let trigram = pack_trigram(b'a', b'b', b'c');
5258 let matching_filter = PostingFilter {
5259 next_mask: mask_for_next_char(b'd'),
5260 loc_mask: mask_for_position(0),
5261 };
5262 let non_matching_filter = PostingFilter {
5263 next_mask: mask_for_next_char(b'z'),
5264 loc_mask: mask_for_position(0),
5265 };
5266
5267 assert_eq!(
5268 index
5269 .postings_for_trigram(trigram, Some(matching_filter))
5270 .len(),
5271 1
5272 );
5273 assert!(index
5274 .postings_for_trigram(trigram, Some(non_matching_filter))
5275 .is_empty());
5276 }
5277
5278 #[test]
5279 fn direct_base_decode_matches_materialized_reference_for_all_storage_and_filters() {
5280 let (_dir, index) = lexical_rank_mixed_storage_fixture();
5281 let snapshot = index.snapshot();
5282 let base_only = pack_trigram(b'm', b'a', b'r');
5283 let base_and_delta = pack_trigram(b'a', b'b', b'c');
5284
5285 assert!(snapshot.delta.postings.get(&base_only).is_none());
5286 assert!(snapshot.delta.postings.contains_key(&base_and_delta));
5287 assert!(!snapshot.delta.superseded.is_empty());
5288
5289 let filters = [
5290 None,
5291 Some(PostingFilter::default()),
5292 Some(PostingFilter {
5293 next_mask: mask_for_next_char(b'd'),
5294 loc_mask: 0,
5295 }),
5296 Some(PostingFilter {
5297 next_mask: mask_for_next_char(b'z'),
5298 loc_mask: 0,
5299 }),
5300 Some(PostingFilter {
5301 next_mask: 0,
5302 loc_mask: mask_for_position(0),
5303 }),
5304 Some(PostingFilter {
5305 next_mask: mask_for_next_char(b'd'),
5306 loc_mask: mask_for_position(17),
5307 }),
5308 Some(PostingFilter {
5309 next_mask: mask_for_next_char(b'z'),
5310 loc_mask: mask_for_position(17),
5311 }),
5312 ];
5313
5314 for trigram in [base_only, base_and_delta] {
5315 for filter in filters {
5316 let expected =
5317 postings_for_trigram_materialized_reference(&snapshot, trigram, filter);
5318 let actual = snapshot.postings_for_trigram(trigram, filter);
5319 assert_eq!(
5320 actual, expected,
5321 "trigram={trigram:#08x}, filter={filter:?}"
5322 );
5323 assert!(actual
5324 .iter()
5325 .all(|file_id| !snapshot.delta.superseded.contains(file_id)));
5326 }
5327 }
5328
5329 let unfiltered = snapshot.postings_for_trigram(base_and_delta, None);
5330 let loc_only = snapshot.postings_for_trigram(
5331 base_and_delta,
5332 Some(PostingFilter {
5333 next_mask: 0,
5334 loc_mask: mask_for_position(31),
5335 }),
5336 );
5337 assert_eq!(loc_only, unfiltered);
5338 }
5339
5340 #[test]
5341 fn base_delta_readd_masks_base_and_keeps_postings_sorted() {
5342 let dir = tempfile::tempdir().expect("create temp dir");
5343 let project = dir.path().join("project");
5344 fs::create_dir_all(&project).expect("create project dir");
5345 let a = project.join("a.txt");
5346 let b = project.join("b.txt");
5347 fs::write(&a, "abc old").expect("write a");
5348 fs::write(&b, "abc base").expect("write b");
5349
5350 let mut built = SearchIndex::build(&project);
5351 let cache_dir = dir.path().join("cache");
5352 built.write_to_disk(&cache_dir, None);
5353 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base");
5354 assert_eq!(index.base_file_count, 2);
5355
5356 let old_a_id = *index.path_to_id.get(&a).expect("original a id");
5357 let b_id = *index.path_to_id.get(&b).expect("original b id");
5358 index.remove_file(&a);
5359 index.index_file(&a, b"abc new");
5360 let new_id = *index.path_to_id.get(&a).expect("re-added file id");
5361 assert!(new_id >= index.base_file_count);
5362 let abc = pack_trigram(b'a', b'b', b'c');
5363 let ids = index.postings_for_trigram(abc, None);
5364 assert_eq!(ids, {
5365 let mut expected = vec![b_id, new_id];
5366 expected.sort_unstable();
5367 expected
5368 });
5369 assert!(!ids.contains(&old_a_id));
5370 }
5371
5372 #[test]
5373 fn snapshot_started_before_edit_keeps_coherent_pre_edit_postings() {
5374 let dir = tempfile::tempdir().expect("create temp dir");
5375 let project = dir.path().join("project");
5376 fs::create_dir_all(&project).expect("create project dir");
5377 let project = fs::canonicalize(project).expect("canonicalize project");
5378 let file = project.join("source.txt");
5379 fs::write(&file, "old_generation marker").expect("write old source");
5380
5381 let mut built = SearchIndex::build(&project);
5382 let cache_dir = dir.path().join("cache");
5383 assert!(built.write_to_disk(&cache_dir, None));
5384 let mut index = SearchIndex::read_from_disk(&cache_dir, &project).expect("load base index");
5385 index.ready = true;
5386 let old_file_id = *index.path_to_id.get(&file).expect("old file id");
5387 let index = std::sync::RwLock::new(index);
5388
5389 let before_edit = {
5390 let guard = index.read().expect("read index");
5391 let snapshot = guard.snapshot();
5392 assert!(Arc::ptr_eq(&snapshot.delta, &guard.delta));
5393 snapshot
5394 };
5395
5396 fs::write(&file, "new_generation marker").expect("write new source");
5397 index.write().expect("write index").update_file(&file);
5398
5399 let after_edit = index.read().expect("read updated index").snapshot();
5400 let new_file_id = *after_edit.path_to_id.get(&file).expect("new file id");
5401 assert!(!Arc::ptr_eq(&before_edit.delta, &after_edit.delta));
5402 assert!(!before_edit.delta.superseded.contains(&old_file_id));
5403 assert!(after_edit.delta.superseded.contains(&old_file_id));
5404
5405 let old_trigram = pack_trigram(b'o', b'l', b'd');
5406 let new_trigram = pack_trigram(b'n', b'e', b'w');
5407 assert_eq!(
5408 before_edit.postings_for_trigram(old_trigram, None),
5409 vec![old_file_id]
5410 );
5411 assert!(before_edit
5412 .postings_for_trigram(new_trigram, None)
5413 .is_empty());
5414 assert!(after_edit
5415 .postings_for_trigram(old_trigram, None)
5416 .is_empty());
5417 assert_eq!(
5418 after_edit.postings_for_trigram(new_trigram, None),
5419 vec![new_file_id]
5420 );
5421
5422 let dirty_result = index.read().expect("read dirty index").grep(
5423 "new_generation",
5424 true,
5425 &[],
5426 &[],
5427 &project,
5428 100,
5429 );
5430 let reference_cache = dir.path().join("reference-cache");
5431 let reference = SearchIndex::build_with_limit_to_cache_dir(
5432 &project,
5433 DEFAULT_MAX_FILE_SIZE,
5434 &reference_cache,
5435 );
5436 let reference_result = reference.grep("new_generation", true, &[], &[], &project, 100);
5437 assert_eq!(dirty_result.matches, reference_result.matches);
5438 assert_eq!(dirty_result.total_matches, reference_result.total_matches);
5439 }
5440
5441 #[test]
5442 fn lexical_rank_cached_postings_match_reference_for_base_delta_and_superseded_files() {
5443 let (_dir, index) = lexical_rank_mixed_storage_fixture();
5444 let long_query = query_trigrams_from_tokens(&["abcdefghij"]);
5445 assert!(long_query.len() > 3);
5446 let long_result = assert_rank_matches_reference(&index, &long_query, None, 1_000);
5447 assert!(long_result.engine_capped);
5448
5449 let short_query = query_trigrams_from_tokens(&["abc"]);
5450 assert_eq!(short_query.len(), 1);
5451 assert_rank_matches_reference(&index, &short_query, None, 100);
5452
5453 let mixed_query = query_trigrams_from_tokens(&["sharedalpha", "absentzzz"]);
5454 let production_only = |path: &Path| !path.ends_with("file_002.txt");
5455 assert_rank_matches_reference(&index, &mixed_query, Some(&production_only), 25);
5456
5457 let mut duplicate_query = query_trigrams_from_tokens(&["abcdefghij"]);
5458 duplicate_query.push(duplicate_query[0]);
5459 assert_rank_matches_reference(&index, &duplicate_query, None, 40);
5460 }
5461
5462 #[cfg(debug_assertions)]
5463 #[test]
5464 fn lexical_rank_reads_each_distinct_query_posting_list_once() {
5465 let (_dir, index) = lexical_rank_mixed_storage_fixture();
5466 let mut query = query_trigrams_from_tokens(&["abcdefghij", "sharedalpha"]);
5467 query.push(query[0]);
5468 let distinct_trigrams = query.iter().copied().collect::<HashSet<_>>().len();
5469
5470 reset_postings_for_trigram_count_for_debug();
5471 let result = index
5472 .snapshot()
5473 .lexical_rank_with_stats(&query, None, 1_000);
5474
5475 assert!(result.files.len() > 1);
5476 assert_eq!(
5477 postings_for_trigram_count_for_debug(),
5478 distinct_trigrams,
5479 "candidate discovery and scoring must share query-local posting lists"
5480 );
5481 }
5482
5483 #[test]
5484 fn borrow_only_root_skips_shared_lock_persist_and_streaming_spills() {
5485 let dir = tempfile::tempdir().expect("temp dir");
5486 let project = dir.path().join("project");
5487 fs::create_dir_all(&project).expect("project dir");
5488 fs::write(project.join("source.txt"), "borrow only search index").expect("source file");
5489 let project_key = "shared-artifact-key".to_string();
5490 let cache_dir = dir.path().join("index").join(&project_key);
5491 crate::root_cache::configure_artifact_access(&project, &project_key, true);
5492
5493 let _lock = CacheLock::acquire(&cache_dir, &project).expect("borrow-only lock downgrade");
5494 assert!(!cache_dir.join("cache.lock").exists());
5495
5496 let mut index =
5497 SearchIndex::build_with_limit_to_cache_dir(&project, DEFAULT_MAX_FILE_SIZE, &cache_dir);
5498 assert!(!index.ready);
5499 index.write_to_disk(&cache_dir, None);
5500
5501 assert!(!cache_dir.join("cache.bin").exists());
5502 assert!(!cache_dir.exists());
5503 }
5504
5505 #[test]
5506 fn write_to_disk_compacts_base_and_delta() {
5507 let dir = tempfile::tempdir().expect("create temp dir");
5508 let project = dir.path().join("project");
5509 fs::create_dir_all(&project).expect("create project dir");
5510 let file = project.join("src.txt");
5511 fs::write(&file, "abcdef").expect("write source");
5512 let mut index = SearchIndex::build(&project);
5513 let cache_dir = dir.path().join("cache");
5514 index.write_to_disk(&cache_dir, None);
5515 fs::write(&file, "abcxyz").expect("edit source");
5516 index.update_file(&file);
5517 assert!(!index.delta.postings.is_empty());
5518 index.write_to_disk(&cache_dir, None);
5519 assert!(index.delta.postings.is_empty());
5520 assert!(index.delta.superseded.is_empty());
5521 assert_eq!(
5522 index.postings_for_trigram(pack_trigram(b'a', b'b', b'c'), None),
5523 vec![0]
5524 );
5525 assert!(index
5526 .postings_for_trigram(pack_trigram(b'd', b'e', b'f'), None)
5527 .is_empty());
5528 }
5529
5530 #[test]
5531 fn legacy_cache_without_file_trigram_count_migrates_streaming_counts() {
5532 let dir = tempfile::tempdir().expect("create temp dir");
5533 let project = dir.path().join("project");
5534 fs::create_dir_all(&project).expect("create project dir");
5535 fs::write(project.join("src.txt"), "abcdef").expect("write source");
5536 let cache_dir = dir.path().join("cache");
5537 let mut index = SearchIndex::build(&project);
5538 index.write_to_disk(&cache_dir, None);
5539 let cache_path = cache_dir.join("cache.bin");
5540 strip_file_trigram_count_extension(&cache_path);
5541 assert!(!cache_has_file_trigram_count_extension(&cache_path));
5542
5543 let loaded = SearchIndex::read_from_disk(&cache_dir, &project).expect("load legacy cache");
5544 assert_eq!(loaded.file_trigram_count.as_ref(), &[4]);
5545 assert!(loaded.delta.postings.is_empty());
5546 assert!(cache_has_file_trigram_count_extension(&cache_path));
5547 }
5548
5549 #[test]
5550 fn compaction_flags_buffer_paths_while_running() {
5551 let dir = tempfile::tempdir().expect("create temp dir");
5552 let project = dir.path().join("project");
5553 fs::create_dir_all(&project).expect("create project dir");
5554 let file = project.join("src.txt");
5555 fs::write(&file, "abcdef").expect("write source");
5556 let mut index = SearchIndex::new();
5557 index.project_root = project.clone();
5558 {
5559 let mut state = index.compaction_state.lock().expect("compaction state");
5560 state.running = true;
5561 }
5562 index.update_file(&file);
5563 let state = index.compaction_state.lock().expect("compaction state");
5564 assert!(state.requested_again || !index.delta.postings.is_empty());
5565 assert!(state.buffered_paths.contains(&file));
5566 }
5567
5568 fn cache_has_file_trigram_count_extension(cache_path: &Path) -> bool {
5569 file_trigram_count_extension_range(cache_path).is_some()
5570 }
5571
5572 fn strip_file_trigram_count_extension(cache_path: &Path) {
5573 let mut bytes = fs::read(cache_path).expect("read cache");
5574 let (start, end) = file_trigram_count_extension_range_from_bytes(&bytes)
5575 .expect("file trigram count extension");
5576 bytes.drain(start..end);
5577 let postings_len_total = u64::from_le_bytes(bytes[8..16].try_into().unwrap())
5578 - u64::try_from(end - start).unwrap();
5579 bytes[8..16].copy_from_slice(&postings_len_total.to_le_bytes());
5580 let checksum_pos = 16 + usize::try_from(postings_len_total).unwrap() - 4;
5581 let checksum = crc32fast::hash(&bytes[16..checksum_pos]);
5582 bytes[checksum_pos..checksum_pos + 4].copy_from_slice(&checksum.to_le_bytes());
5583 fs::write(cache_path, bytes).expect("write legacy cache");
5584 }
5585
5586 fn file_trigram_count_extension_range(cache_path: &Path) -> Option<(usize, usize)> {
5587 let bytes = fs::read(cache_path).ok()?;
5588 file_trigram_count_extension_range_from_bytes(&bytes)
5589 }
5590
5591 fn file_trigram_count_extension_range_from_bytes(bytes: &[u8]) -> Option<(usize, usize)> {
5592 let postings_len_total = u64::from_le_bytes(bytes.get(8..16)?.try_into().ok()?) as usize;
5593 let postings_start = 16usize;
5594 let postings_end = postings_start.checked_add(postings_len_total)?;
5595 let postings_body_end = postings_end.checked_sub(4)?;
5596 let mut reader = Cursor::new(&bytes[postings_start..postings_body_end]);
5597 let mut magic = [0u8; 8];
5598 reader.read_exact(&mut magic).ok()?;
5599 if &magic != INDEX_MAGIC {
5600 return None;
5601 }
5602 read_u32(&mut reader).ok()?;
5603 let head_len = read_u32(&mut reader).ok()? as u64;
5604 let root_len = read_u32(&mut reader).ok()? as u64;
5605 let ignore_len = read_u32(&mut reader).ok()? as u64;
5606 read_u64(&mut reader).ok()?;
5607 let file_count = read_u32(&mut reader).ok()? as usize;
5608 let skip = head_len.checked_add(root_len)?.checked_add(ignore_len)?;
5609 reader.seek(SeekFrom::Current(skip as i64)).ok()?;
5610 for _ in 0..file_count {
5611 let mut unindexed = [0u8; 1];
5612 reader.read_exact(&mut unindexed).ok()?;
5613 let path_len = read_u32(&mut reader).ok()? as u64;
5614 read_u64(&mut reader).ok()?;
5615 read_u64(&mut reader).ok()?;
5616 read_u32(&mut reader).ok()?;
5617 let mut hash = [0u8; 32];
5618 reader.read_exact(&mut hash).ok()?;
5619 reader.seek(SeekFrom::Current(path_len as i64)).ok()?;
5620 }
5621 let postings_blob_len = read_u64(&mut reader).ok()? as usize;
5622 let extension_start = postings_start
5623 .checked_add(reader.position() as usize)?
5624 .checked_add(postings_blob_len)?;
5625 if extension_start + 16 > postings_body_end {
5626 return None;
5627 }
5628 if bytes.get(extension_start..extension_start + 8)? != FILE_TRIGRAM_COUNT_MAGIC {
5629 return None;
5630 }
5631 let count = u32::from_le_bytes(
5632 bytes[extension_start + 12..extension_start + 16]
5633 .try_into()
5634 .ok()?,
5635 ) as usize;
5636 let extension_end = extension_start
5637 .checked_add(16)?
5638 .checked_add(count.checked_mul(4)?)?;
5639 (extension_end <= postings_body_end).then_some((extension_start, extension_end))
5640 }
5641
5642 #[test]
5643 fn disk_round_trip_preserves_postings_and_files() {
5644 let dir = tempfile::tempdir().expect("create temp dir");
5645 let project = dir.path().join("project");
5646 fs::create_dir_all(&project).expect("create project dir");
5647 let file = project.join("src.txt");
5648 fs::write(&file, "abcdef").expect("write source");
5649
5650 let mut index = SearchIndex::build(&project);
5651 index.git_head = Some("deadbeef".to_string());
5652 let cache_dir = dir.path().join("cache");
5653 let head = index.git_head.clone();
5654 index.write_to_disk(&cache_dir, head.as_deref());
5655
5656 let loaded =
5657 SearchIndex::read_from_disk(&cache_dir, &project).expect("load index from disk");
5658 assert_eq!(loaded.stored_git_head(), Some("deadbeef"));
5659 assert_eq!(loaded.files.len(), 1);
5660 assert_eq!(
5661 relative_to_root(&loaded.project_root, &loaded.files[0].path),
5662 PathBuf::from("src.txt")
5663 );
5664 assert_eq!(loaded.trigram_count(), index.trigram_count());
5665 assert_eq!(
5666 loaded.postings_for_trigram(pack_trigram(b'a', b'b', b'c'), None),
5667 vec![0]
5668 );
5669 assert_eq!(
5670 loaded.file_trigram_count.as_ref(),
5671 index.file_trigram_count.as_ref()
5672 );
5673 }
5674
5675 #[test]
5676 fn cache_path_helpers_reject_absolute_and_parent_paths() {
5677 let root = PathBuf::from("/tmp/aft-project");
5678
5679 assert_eq!(
5680 cache_relative_path(&root, &root.join("src/lib.rs")),
5681 Some(PathBuf::from("src/lib.rs"))
5682 );
5683 assert!(cache_relative_path(&root, Path::new("/tmp/outside.rs")).is_none());
5684 assert!(cached_path_under_root(&root, Path::new("../outside.rs")).is_none());
5685 assert!(cached_path_under_root(&root, Path::new("/tmp/outside.rs")).is_none());
5686 assert_eq!(
5687 cached_path_under_root(&root, Path::new("src/./lib.rs")),
5688 Some(root.join("src/lib.rs"))
5689 );
5690 }
5691
5692 fn git_command_for_test(root: &Path) -> Command {
5693 let mut command = Command::new("git");
5694 crate::test_env::apply_hermetic_git_env(command.arg("-C").arg(root));
5695 command
5696 }
5697
5698 #[test]
5699 fn refresh_after_head_change_removes_renames_and_detects_local_files() {
5700 let _git_env = crate::test_env::hermetic_git_env_guard();
5701 let dir = tempfile::tempdir().expect("create temp dir");
5702 let project = dir.path().join("project");
5703 fs::create_dir_all(&project).expect("create project dir");
5704 let canonical_project = fs::canonicalize(&project).expect("canonical project");
5705 fs::write(project.join("old.txt"), "old token\n").expect("write old");
5706 fs::write(project.join("unchanged.txt"), "before\n").expect("write unchanged");
5707
5708 let mut init = Command::new("git");
5709 crate::test_env::apply_hermetic_git_env(init.arg("init").arg(&project))
5710 .status()
5711 .expect("git init");
5712 for args in [
5713 ["config", "user.email", "aft@example.invalid"],
5714 ["config", "user.name", "AFT Test"],
5715 ] {
5716 git_command_for_test(&project)
5717 .args(args)
5718 .status()
5719 .expect("git config");
5720 }
5721 git_command_for_test(&project)
5722 .args(["add", "."])
5723 .status()
5724 .expect("git add initial");
5725 git_command_for_test(&project)
5726 .args(["commit", "-m", "initial"])
5727 .status()
5728 .expect("git commit initial");
5729 let previous = run_git(&project, &["rev-parse", "HEAD"]).expect("previous head");
5730 let mut baseline = SearchIndex::build(&project);
5731 baseline.git_head = Some(previous.clone());
5732
5733 fs::rename(project.join("old.txt"), project.join("new.txt")).expect("rename file");
5734 git_command_for_test(&project)
5735 .args(["add", "-A"])
5736 .status()
5737 .expect("git add rename");
5738 git_command_for_test(&project)
5739 .args(["commit", "-m", "rename"])
5740 .status()
5741 .expect("git commit rename");
5742 let current = run_git(&project, &["rev-parse", "HEAD"]).expect("current head");
5743
5744 fs::write(project.join("unchanged.txt"), "after local edit\n").expect("local edit");
5745 fs::write(project.join("untracked.txt"), "untracked token\n").expect("untracked");
5746
5747 let refreshed = SearchIndex::rebuild_or_refresh(
5748 &project,
5749 DEFAULT_MAX_FILE_SIZE,
5750 Some(current),
5751 Some(baseline),
5752 None,
5753 );
5754
5755 assert!(!refreshed
5756 .path_to_id
5757 .contains_key(&canonical_project.join("old.txt")));
5758 assert!(refreshed
5759 .path_to_id
5760 .contains_key(&canonical_project.join("new.txt")));
5761 assert!(refreshed
5762 .path_to_id
5763 .contains_key(&canonical_project.join("untracked.txt")));
5764 let matches = refreshed.grep("after local edit", true, &[], &[], &canonical_project, 10);
5765 assert_eq!(matches.matches.len(), 1);
5766 }
5767
5768 #[test]
5769 fn read_from_disk_rejects_corrupt_lookup_checksum() {
5770 let dir = tempfile::tempdir().expect("create temp dir");
5771 let project = dir.path().join("project");
5772 fs::create_dir_all(&project).expect("create project dir");
5773 fs::write(project.join("src.txt"), "abcdef").expect("write source");
5774
5775 let mut index = SearchIndex::build(&project);
5776 let cache_dir = dir.path().join("cache");
5777 index.write_to_disk(&cache_dir, None);
5778
5779 let cache_path = cache_dir.join("cache.bin");
5780 let mut bytes = fs::read(&cache_path).expect("read cache");
5781 let last = bytes.len() - 1;
5782 bytes[last] ^= 0xff;
5783 fs::write(&cache_path, bytes).expect("write corrupted cache");
5784
5785 assert!(SearchIndex::read_from_disk(&cache_dir, &project).is_none());
5786 }
5787
5788 #[test]
5789 fn write_to_disk_uses_temp_files_and_cleans_them_up() {
5790 let dir = tempfile::tempdir().expect("create temp dir");
5791 let project = dir.path().join("project");
5792 fs::create_dir_all(&project).expect("create project dir");
5793 fs::write(project.join("src.txt"), "abcdef").expect("write source");
5794
5795 let mut index = SearchIndex::build(&project);
5796 let cache_dir = dir.path().join("cache");
5797 index.write_to_disk(&cache_dir, None);
5798
5799 assert!(cache_dir.join("cache.bin").is_file());
5800 assert!(fs::read_dir(&cache_dir)
5801 .expect("read cache dir")
5802 .all(|entry| !entry
5803 .expect("cache entry")
5804 .file_name()
5805 .to_string_lossy()
5806 .contains(".tmp.")));
5807 }
5808
5809 #[test]
5810 fn concurrent_search_index_writes_do_not_corrupt() {
5811 let dir = tempfile::tempdir().expect("create temp dir");
5812 let project = dir.path().join("project");
5813 fs::create_dir_all(&project).expect("create project dir");
5814 fs::write(project.join("src.txt"), "abcdef\n").expect("write source");
5815 let cache_dir = dir.path().join("cache");
5816
5817 let a_project = project.clone();
5818 let a_cache = cache_dir.clone();
5819 let a = std::thread::spawn(move || {
5820 let _lock = CacheLock::acquire(&a_cache, &a_project).expect("acquire cache lock a");
5821 let mut index = SearchIndex::build(&a_project);
5822 index.write_to_disk(&a_cache, None);
5823 });
5824 let b_project = project.clone();
5825 let b_cache = cache_dir.clone();
5826 let b = std::thread::spawn(move || {
5827 let _lock = CacheLock::acquire(&b_cache, &b_project).expect("acquire cache lock b");
5828 let mut index = SearchIndex::build(&b_project);
5829 index.write_to_disk(&b_cache, None);
5830 });
5831 a.join().expect("writer a");
5832 b.join().expect("writer b");
5833
5834 assert!(SearchIndex::read_from_disk(&cache_dir, &project).is_some());
5835 }
5836
5837 #[test]
5838 fn search_index_atomic_rename_survives_partial_write() {
5839 let dir = tempfile::tempdir().expect("create temp dir");
5840 let cache_dir = dir.path().join("cache");
5841 fs::create_dir_all(&cache_dir).expect("create cache dir");
5842 fs::write(cache_dir.join("cache.bin.tmp.1.1"), b"partial").expect("write partial tmp");
5843
5844 assert!(SearchIndex::read_from_disk(&cache_dir, dir.path()).is_none());
5845 }
5846
5847 fn grafted_history_test_roots() -> [&'static str; 3] {
5848 [
5849 "7e96b9e0000000000000000000000000000000",
5850 "1e394c20000000000000000000000000000000",
5851 "40587520000000000000000000000000000000",
5852 ]
5853 }
5854
5855 fn artifact_key_from_root_commit_output(stdout: &[u8]) -> String {
5856 match canonicalize_root_commit_output(stdout) {
5857 RootCommitProbe::Commit(root_commit) => artifact_key_from_git_identity(&root_commit),
5858 RootCommitProbe::NoCommit => panic!("root commit output unexpectedly empty"),
5859 RootCommitProbe::NotARepo => panic!("root commit output was not a repository"),
5860 RootCommitProbe::Transient(detail) => panic!("root commit output failed: {detail}"),
5861 }
5862 }
5863
5864 fn force_git_root_commit_probe_outputs_for_test(
5865 outputs_by_root: BTreeMap<PathBuf, Vec<u8>>,
5866 ) -> GitRootCommitProbeOverrideGuard {
5867 install_git_root_commit_probe_override_for_test(move |project_root| {
5868 outputs_by_root
5869 .get(project_root)
5870 .map(|stdout| canonicalize_root_commit_output(stdout))
5871 })
5872 }
5873
5874 #[test]
5875 fn artifact_cache_key_canonicalizes_all_root_permutations() {
5876 let roots = grafted_history_test_roots();
5877 let mut sorted_roots = roots;
5878 sorted_roots.sort_unstable();
5879 let expected_commit = sorted_roots.join("\n");
5880 let expected_key = artifact_key_from_git_identity(&expected_commit);
5881
5882 let permutations = [
5883 [roots[0], roots[1], roots[2]],
5884 [roots[0], roots[2], roots[1]],
5885 [roots[1], roots[0], roots[2]],
5886 [roots[1], roots[2], roots[0]],
5887 [roots[2], roots[0], roots[1]],
5888 [roots[2], roots[1], roots[0]],
5889 ];
5890 for permutation in permutations {
5891 let output = format!("{}\n", permutation.join("\n"));
5892 let RootCommitProbe::Commit(canonical) =
5893 canonicalize_root_commit_output(output.as_bytes())
5894 else {
5895 panic!("root permutation did not produce a commit");
5896 };
5897 assert_eq!(canonical, expected_commit);
5898 assert_eq!(artifact_key_from_git_identity(&canonical), expected_key);
5899 }
5900 }
5901
5902 #[test]
5903 fn artifact_cache_key_deduplicates_repeated_roots() {
5904 let root = grafted_history_test_roots()[0];
5905 let one_root = format!("{root}\n");
5906 let duplicate_root = format!("{root}\n{root}\n");
5907
5908 assert_eq!(
5909 artifact_key_from_root_commit_output(one_root.as_bytes()),
5910 artifact_key_from_root_commit_output(duplicate_root.as_bytes())
5911 );
5912 }
5913
5914 #[test]
5915 fn artifact_cache_key_ignores_blank_whitespace_and_crlf_lines() {
5916 let roots = grafted_history_test_roots();
5917 let clean = format!("{}\n{}\n", roots[0], roots[1]);
5918 let decorated = format!(" \r\n\t{} \r\n{}\t\r\n\r\n", roots[1], roots[0]);
5919
5920 assert_eq!(
5921 artifact_key_from_root_commit_output(clean.as_bytes()),
5922 artifact_key_from_root_commit_output(decorated.as_bytes())
5923 );
5924 }
5925
5926 #[test]
5927 fn artifact_cache_key_single_root_matches_the_old_trimmed_derivation() {
5928 let root = grafted_history_test_roots()[0];
5929 let output = format!("{root}\n");
5930 let old_trimmed = String::from_utf8_lossy(output.as_bytes())
5931 .trim()
5932 .to_string();
5933 let old_key = artifact_hash16(old_trimmed.as_bytes());
5934
5935 let RootCommitProbe::Commit(canonical) = canonicalize_root_commit_output(output.as_bytes())
5936 else {
5937 panic!("single root output did not produce a commit");
5938 };
5939 assert_eq!(canonical, root);
5940 assert_eq!(artifact_key_from_git_identity(&canonical), old_key);
5941 }
5942
5943 #[test]
5944 fn artifact_cache_key_empty_canonical_root_output_is_no_commit() {
5945 assert!(matches!(
5946 canonicalize_root_commit_output(b" \r\n\t\n\r\n"),
5947 RootCommitProbe::NoCommit
5948 ));
5949 }
5950
5951 #[test]
5952 fn artifact_cache_key_memo_round_trip_uses_canonical_root_set() {
5953 let _probe_lock = git_root_commit_probe_override_lock_for_test();
5954 let dir = tempfile::tempdir().expect("create temp dir");
5955 let storage = dir.path().join("storage");
5956 let root = git_like_root(&dir, "repo");
5957 let roots = grafted_history_test_roots();
5958 let mut sorted_roots = roots;
5959 sorted_roots.sort_unstable();
5960 let canonical = sorted_roots.join("\n");
5961 let expected_key = artifact_key_from_git_identity(&canonical);
5962 let first_output = format!("{}\n{}\n{}\n", roots[2], roots[0], roots[1]);
5963 let second_output = format!("{}\n{}\n{}\n", roots[1], roots[2], roots[0]);
5964
5965 {
5966 let mut outputs = BTreeMap::new();
5967 outputs.insert(root.clone(), first_output.into_bytes());
5968 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
5969 assert_eq!(
5970 artifact_cache_key_with_memo(&root, &root, &storage, None)
5971 .expect("first canonical key"),
5972 expected_key
5973 );
5974 }
5975 let first_memo_bytes =
5976 fs::read(artifact_cache_key_memo_path(&storage)).expect("read first memo bytes");
5977
5978 {
5979 let mut outputs = BTreeMap::new();
5980 outputs.insert(root.clone(), second_output.into_bytes());
5981 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
5982 assert_eq!(
5983 artifact_cache_key_with_memo(&root, &root, &storage, None)
5984 .expect("second canonical key"),
5985 expected_key
5986 );
5987 }
5988 let second_memo_bytes =
5989 fs::read(artifact_cache_key_memo_path(&storage)).expect("read second memo bytes");
5990 assert_eq!(
5991 first_memo_bytes, second_memo_bytes,
5992 "an unchanged canonical memo entry should not be rewritten"
5993 );
5994 let memo = read_cache_key_memo(&storage);
5995 assert_eq!(
5996 memo.get(root.to_string_lossy().as_ref())
5997 .expect("canonical memo entry")
5998 .git_root_commit,
5999 canonical
6000 );
6001 }
6002
6003 #[test]
6004 fn artifact_cache_key_replaces_old_unsorted_memo_entry() {
6005 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6006 let dir = tempfile::tempdir().expect("create temp dir");
6007 let storage = dir.path().join("storage");
6008 let root = git_like_root(&dir, "repo");
6009 let roots = grafted_history_test_roots();
6010 let unsorted = format!("{}\n{}", roots[2], roots[0]);
6011 let mut sorted_roots = [roots[2], roots[0]];
6012 sorted_roots.sort_unstable();
6013 let canonical = sorted_roots.join("\n");
6014 let expected_key = artifact_key_from_git_identity(&canonical);
6015
6016 let mut seeded_memo = BTreeMap::new();
6017 seeded_memo.insert(
6018 root.to_string_lossy().into_owned(),
6019 ArtifactCacheKeyMemoEntry {
6020 key: artifact_key_from_git_identity(&unsorted),
6021 git_root_commit: unsorted.clone(),
6022 recorded_at_ms: 1,
6023 },
6024 );
6025 fs::create_dir_all(&storage).expect("create memo storage");
6026 fs::write(
6027 artifact_cache_key_memo_path(&storage),
6028 serde_json::to_vec_pretty(&seeded_memo).expect("serialize seeded memo"),
6029 )
6030 .expect("write seeded memo");
6031
6032 let mut outputs = BTreeMap::new();
6033 outputs.insert(root.clone(), unsorted.into_bytes());
6034 let _override = force_git_root_commit_probe_outputs_for_test(outputs);
6035 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
6036 .expect("canonical probe should replace old memo");
6037
6038 assert_eq!(key, expected_key);
6039 let memo = read_cache_key_memo(&storage);
6040 let entry = memo
6041 .get(root.to_string_lossy().as_ref())
6042 .expect("replaced memo entry");
6043 assert_eq!(entry.key, expected_key);
6044 assert_eq!(entry.git_root_commit, canonical);
6045 }
6046
6047 #[test]
6048 fn artifact_cache_key_shared_across_clones_of_same_repo() {
6049 let _git_env = crate::test_env::hermetic_git_env_guard();
6050 let dir = tempfile::tempdir().expect("create temp dir");
6051 let source = dir.path().join("source");
6052 fs::create_dir_all(&source).expect("create source repo dir");
6053 fs::write(source.join("tracked.txt"), "content\n").expect("write tracked file");
6054
6055 let mut init = Command::new("git");
6056 assert!(
6057 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
6058 .args(["init"])
6059 .status()
6060 .expect("init git repo")
6061 .success()
6062 );
6063 assert!(git_command_for_test(&source)
6064 .args(["add", "."])
6065 .status()
6066 .expect("git add")
6067 .success());
6068 assert!(git_command_for_test(&source)
6069 .args([
6070 "-c",
6071 "user.name=AFT Tests",
6072 "-c",
6073 "user.email=aft-tests@example.com",
6074 "commit",
6075 "-m",
6076 "initial",
6077 ])
6078 .status()
6079 .expect("git commit")
6080 .success());
6081
6082 let clone = dir.path().join("clone");
6083 let mut clone_command = Command::new("git");
6084 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
6085 .args(["clone", "--quiet"])
6086 .arg(&source)
6087 .arg(&clone)
6088 .status()
6089 .expect("git clone")
6090 .success());
6091
6092 let source_key = artifact_cache_key(&source);
6093 let clone_key = artifact_cache_key(&clone);
6094
6095 assert_eq!(source_key.len(), 16);
6096 assert_eq!(clone_key.len(), 16);
6097 assert_eq!(source_key, clone_key);
6099 }
6100
6101 fn read_cache_key_memo(storage_root: &Path) -> BTreeMap<String, ArtifactCacheKeyMemoEntry> {
6102 let bytes = fs::read(artifact_cache_key_memo_path(storage_root)).expect("read memo file");
6103 serde_json::from_slice(&bytes).expect("parse memo file")
6104 }
6105
6106 fn git_like_root(dir: &tempfile::TempDir, name: &str) -> PathBuf {
6107 let root = dir.path().join(name);
6108 fs::create_dir_all(root.join(".git")).expect("create git marker");
6109 root
6110 }
6111
6112 #[test]
6113 fn artifact_cache_key_success_writes_memo() {
6114 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6115 let dir = tempfile::tempdir().expect("create temp dir");
6116 let storage = dir.path().join("storage");
6117 let root = git_like_root(&dir, "repo");
6118 let commit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
6119 let mut commits = BTreeMap::new();
6120 commits.insert(root.clone(), commit.clone());
6121 let _override = force_git_root_commit_probe_commits_for_test(commits);
6122
6123 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
6124 .expect("cache key from successful probe");
6125
6126 assert_eq!(key, artifact_key_from_git_identity(&commit));
6127 let memo = read_cache_key_memo(&storage);
6128 let entry = memo
6129 .get(root.to_string_lossy().as_ref())
6130 .expect("memo entry for root");
6131 assert_eq!(entry.key, key);
6132 assert_eq!(entry.git_root_commit, commit);
6133 assert!(entry.recorded_at_ms > 0);
6134 }
6135
6136 #[test]
6137 fn artifact_cache_key_probe_failure_uses_memoized_key() {
6138 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6139 let dir = tempfile::tempdir().expect("create temp dir");
6140 let storage = dir.path().join("storage");
6141 let root = git_like_root(&dir, "repo");
6142 let commit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
6143 let expected_key = artifact_key_from_git_identity(&commit);
6144 {
6145 let mut commits = BTreeMap::new();
6146 commits.insert(root.clone(), commit);
6147 let _override = force_git_root_commit_probe_commits_for_test(commits);
6148 assert_eq!(
6149 artifact_cache_key_with_memo(&root, &root, &storage, None).expect("initial key"),
6150 expected_key
6151 );
6152 }
6153 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
6154 vec![root.clone()],
6155 "spawn failed: Too many open files (os error 24)",
6156 );
6157
6158 let rescued = artifact_cache_key_with_memo(&root, &root, &storage, None)
6159 .expect("memo should rescue transient probe failure");
6160
6161 assert_eq!(rescued, expected_key);
6162 assert_ne!(rescued, artifact_key_from_path_identity(&root));
6163 }
6164
6165 #[test]
6166 fn artifact_cache_key_probe_failure_without_memo_rejects_git_like_root() {
6167 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6168 let dir = tempfile::tempdir().expect("create temp dir");
6169 let storage = dir.path().join("storage");
6170 let root = git_like_root(&dir, "repo");
6171 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
6172 vec![root.clone()],
6173 "spawn failed: Too many open files (os error 24)",
6174 );
6175
6176 let error = artifact_cache_key_with_memo(&root, &root, &storage, None)
6177 .expect_err("git-like root without memo must not use path identity");
6178
6179 assert_eq!(error.root(), root.as_path());
6180 assert!(error.detail().contains("Too many open files"));
6181 }
6182
6183 #[test]
6184 fn artifact_cache_key_probe_failure_without_git_marker_uses_path_identity() {
6185 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6186 let dir = tempfile::tempdir().expect("create temp dir");
6187 let storage = dir.path().join("storage");
6188 let root = dir.path().join("plain");
6189 fs::create_dir_all(&root).expect("create non-git root");
6190 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
6191 vec![root.clone()],
6192 "spawn failed: Too many open files (os error 24)",
6193 );
6194
6195 let key = artifact_cache_key_with_memo(&root, &root, &storage, None)
6196 .expect("non-git root keeps legacy path identity fallback");
6197
6198 assert_eq!(key, artifact_key_from_path_identity(&root));
6199 }
6200
6201 #[test]
6202 fn artifact_cache_key_corrupt_memo_is_absent_not_a_path_identity_escape() {
6203 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6204 let dir = tempfile::tempdir().expect("create temp dir");
6205 let storage = dir.path().join("storage");
6206 fs::create_dir_all(&storage).expect("create storage root");
6207 fs::write(artifact_cache_key_memo_path(&storage), b"not json").expect("write corrupt memo");
6208 let root = git_like_root(&dir, "repo");
6209 let _override = force_git_root_commit_probe_transient_for_paths_for_test(
6210 vec![root.clone()],
6211 "spawn failed: Too many open files (os error 24)",
6212 );
6213
6214 let error = artifact_cache_key_with_memo(&root, &root, &storage, None)
6215 .expect_err("corrupt memo is treated as absent");
6216
6217 assert!(error.detail().contains("Too many open files"));
6218 }
6219
6220 #[test]
6221 fn artifact_cache_key_concurrent_memo_writes_keep_valid_json() {
6222 let _probe_lock = git_root_commit_probe_override_lock_for_test();
6223 let dir = tempfile::tempdir().expect("create temp dir");
6224 let storage = dir.path().join("storage");
6225 let root_a = git_like_root(&dir, "repo-a");
6226 let root_b = git_like_root(&dir, "repo-b");
6227 let commit_a = "cccccccccccccccccccccccccccccccccccccccc".to_string();
6228 let commit_b = "dddddddddddddddddddddddddddddddddddddddd".to_string();
6229 let mut commits = BTreeMap::new();
6230 commits.insert(root_a.clone(), commit_a.clone());
6231 commits.insert(root_b.clone(), commit_b.clone());
6232 let _override = force_git_root_commit_probe_commits_for_test(commits);
6233
6234 let storage_a = storage.clone();
6235 let thread_a = std::thread::spawn({
6236 let root_a = root_a.clone();
6237 move || artifact_cache_key_with_memo(&root_a, &root_a, &storage_a, None)
6238 });
6239 let storage_b = storage.clone();
6240 let thread_b = std::thread::spawn({
6241 let root_b = root_b.clone();
6242 move || artifact_cache_key_with_memo(&root_b, &root_b, &storage_b, None)
6243 });
6244
6245 let key_a = thread_a.join().expect("join writer a").expect("key a");
6246 let key_b = thread_b.join().expect("join writer b").expect("key b");
6247 let memo = read_cache_key_memo(&storage);
6248
6249 assert_eq!(
6250 memo.get(root_a.to_string_lossy().as_ref())
6251 .expect("root a memo")
6252 .key,
6253 key_a
6254 );
6255 assert_eq!(
6256 memo.get(root_b.to_string_lossy().as_ref())
6257 .expect("root b memo")
6258 .key,
6259 key_b
6260 );
6261 assert_eq!(key_a, artifact_key_from_git_identity(&commit_a));
6262 assert_eq!(key_b, artifact_key_from_git_identity(&commit_b));
6263 }
6264
6265 #[test]
6266 fn git_head_unchanged_picks_up_local_edits() {
6267 let _git_env = crate::test_env::hermetic_git_env_guard();
6268 let dir = tempfile::tempdir().expect("create temp dir");
6269 let project = dir.path().join("repo");
6270 fs::create_dir_all(&project).expect("create repo dir");
6271 let file = project.join("tracked.txt");
6272 fs::write(&file, "oldtoken\n").expect("write file");
6273 let mut init = Command::new("git");
6274 assert!(
6275 crate::test_env::apply_hermetic_git_env(init.current_dir(&project))
6276 .arg("init")
6277 .status()
6278 .unwrap()
6279 .success()
6280 );
6281 assert!(git_command_for_test(&project)
6282 .args(["add", "."])
6283 .status()
6284 .unwrap()
6285 .success());
6286 assert!(git_command_for_test(&project)
6287 .args([
6288 "-c",
6289 "user.name=AFT Tests",
6290 "-c",
6291 "user.email=aft-tests@example.com",
6292 "commit",
6293 "-m",
6294 "initial"
6295 ])
6296 .status()
6297 .unwrap()
6298 .success());
6299 let head = current_git_head(&project);
6300 let mut baseline = SearchIndex::build(&project);
6301 baseline.git_head = head.clone();
6302 fs::write(&file, "newtoken\n").expect("edit tracked file");
6303
6304 let refreshed = SearchIndex::rebuild_or_refresh(
6305 &project,
6306 DEFAULT_MAX_FILE_SIZE,
6307 head,
6308 Some(baseline),
6309 None,
6310 );
6311 let result = refreshed.grep("newtoken", true, &[], &[], &project, 10);
6312
6313 assert_eq!(result.total_matches, 1);
6314 }
6315
6316 #[test]
6317 fn max_file_size_change_reclassifies_unchanged_files() {
6318 let dir = tempfile::tempdir().expect("create temp dir");
6319 let project = dir.path().join("project");
6320 fs::create_dir_all(&project).expect("create project dir");
6321 let file = project.join("file.txt");
6322 fs::write(&file, "unchanged-limit-token-with-enough-bytes\n").expect("write file");
6323
6324 let indexed = SearchIndex::build_with_limit(&project, 128);
6325 assert_eq!(
6326 indexed
6327 .grep("unchanged-limit-token", true, &[], &[], &project, 10)
6328 .total_matches,
6329 1
6330 );
6331 let lowered = SearchIndex::rebuild_or_refresh(&project, 8, None, Some(indexed), None);
6332 let canonical_file = fs::canonicalize(&file).expect("canonical file");
6333 let lowered_id = *lowered
6334 .path_to_id
6335 .get(&canonical_file)
6336 .expect("lowered file id");
6337 assert!(
6338 lowered.unindexed_files.contains(&lowered_id),
6339 "lowering the limit must classify an unchanged oversized file as unindexed"
6340 );
6341
6342 let raised = SearchIndex::rebuild_or_refresh(&project, 128, None, Some(lowered), None);
6343 let raised_id = *raised
6344 .path_to_id
6345 .get(&canonical_file)
6346 .expect("raised file id");
6347 assert!(
6348 !raised.unindexed_files.contains(&raised_id),
6349 "raising the limit must index a previously unindexed unchanged file"
6350 );
6351 assert_eq!(
6352 raised
6353 .grep("unchanged-limit-token", true, &[], &[], &project, 10)
6354 .total_matches,
6355 1
6356 );
6357 }
6358
6359 #[test]
6360 fn non_git_project_reuses_cache_when_files_unchanged() {
6361 let dir = tempfile::tempdir().expect("create temp dir");
6362 let project = dir.path().join("project");
6363 fs::create_dir_all(&project).expect("create project dir");
6364 fs::write(project.join("file.txt"), "unchangedtoken\n").expect("write file");
6365 let baseline = SearchIndex::build(&project);
6366 let baseline_file_count = baseline.file_count();
6367
6368 let refreshed = SearchIndex::rebuild_or_refresh(
6369 &project,
6370 DEFAULT_MAX_FILE_SIZE,
6371 None,
6372 Some(baseline),
6373 None,
6374 );
6375
6376 assert_eq!(refreshed.file_count(), baseline_file_count);
6377 assert_eq!(
6378 refreshed
6379 .grep("unchangedtoken", true, &[], &[], &project, 10)
6380 .total_matches,
6381 1
6382 );
6383 }
6384
6385 #[test]
6386 fn resolve_search_scope_disables_index_for_external_path() {
6387 let dir = tempfile::tempdir().expect("create temp dir");
6388 let project = dir.path().join("project");
6389 let outside = dir.path().join("outside");
6390 fs::create_dir_all(&project).expect("create project dir");
6391 fs::create_dir_all(&outside).expect("create outside dir");
6392
6393 let scope = resolve_search_scope(&project, outside.to_str());
6394
6395 assert_eq!(
6396 scope.root,
6397 fs::canonicalize(&outside).expect("canonicalize outside")
6398 );
6399 assert!(!scope.use_index);
6400 }
6401
6402 #[test]
6403 fn grep_filters_matches_to_search_root() {
6404 let dir = tempfile::tempdir().expect("create temp dir");
6405 let project = dir.path().join("project");
6406 let src = project.join("src");
6407 let docs = project.join("docs");
6408 fs::create_dir_all(&src).expect("create src dir");
6409 fs::create_dir_all(&docs).expect("create docs dir");
6410 fs::write(src.join("main.rs"), "pub struct SearchIndex;\n").expect("write src file");
6411 fs::write(docs.join("guide.md"), "SearchIndex guide\n").expect("write docs file");
6412
6413 let index = SearchIndex::build(&project);
6414 let result = index.grep("SearchIndex", true, &[], &[], &src, 10);
6415
6416 assert_eq!(result.files_searched, 1);
6417 assert_eq!(result.files_with_matches, 1);
6418 assert_eq!(result.matches.len(), 1);
6419 let expected = fs::canonicalize(src.join("main.rs")).expect("canonicalize");
6421 assert_eq!(result.matches[0].file, expected);
6422 }
6423
6424 #[test]
6425 fn grep_deduplicates_multiple_matches_on_same_line() {
6426 let dir = tempfile::tempdir().expect("create temp dir");
6427 let project = dir.path().join("project");
6428 let src = project.join("src");
6429 fs::create_dir_all(&src).expect("create src dir");
6430 fs::write(src.join("main.rs"), "SearchIndex SearchIndex\n").expect("write src file");
6431
6432 let index = SearchIndex::build(&project);
6433 let result = index.grep("SearchIndex", true, &[], &[], &src, 10);
6434
6435 assert_eq!(result.total_matches, 1);
6436 assert_eq!(result.matches.len(), 1);
6437 }
6438
6439 #[test]
6440 fn grep_case_insensitive_unicode_literal_matches_indexed_file() {
6441 let dir = tempfile::tempdir().expect("create temp dir");
6442 let project = dir.path().join("project");
6443 fs::create_dir_all(&project).expect("create project dir");
6444 let file = project.join("unicode.txt");
6445 fs::write(&file, "äbc\n").expect("write unicode file");
6446
6447 let index = SearchIndex::build(&project);
6448 let result = index.grep("Äbc", false, &[], &[], &project, 10);
6449
6450 assert_eq!(result.total_matches, 1);
6451 assert_eq!(result.matches.len(), 1);
6452 assert_eq!(
6453 result.matches[0].file,
6454 fs::canonicalize(file).expect("canonicalize unicode file")
6455 );
6456 }
6457
6458 #[test]
6459 fn refresh_reindexes_same_size_edit_with_preserved_mtime() {
6460 let dir = tempfile::tempdir().expect("create temp dir");
6461 let project = dir.path().join("project");
6462 fs::create_dir_all(&project).expect("create project dir");
6463 let file = project.join("tokens.txt");
6464 let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
6465 fs::write(&file, "alpha").expect("write original file");
6466 filetime::set_file_mtime(&file, original_mtime).expect("set original mtime");
6467
6468 let baseline = SearchIndex::build(&project);
6469 fs::write(&file, "bravo").expect("write same-size edit");
6470 filetime::set_file_mtime(&file, original_mtime).expect("restore original mtime");
6471
6472 let refreshed = SearchIndex::rebuild_or_refresh(
6473 &project,
6474 DEFAULT_MAX_FILE_SIZE,
6475 None,
6476 Some(baseline),
6477 None,
6478 );
6479 let result = refreshed.grep("bravo", true, &[], &[], &project, 10);
6480 let canonical_file = fs::canonicalize(&file).expect("canonicalize edited file");
6481 let refreshed_id = *refreshed
6482 .path_to_id
6483 .get(&canonical_file)
6484 .expect("file remains indexed");
6485
6486 assert_eq!(result.total_matches, 1);
6487 assert!(refreshed
6488 .postings_for_trigram(pack_trigram(b'b', b'r', b'a'), None)
6489 .contains(&refreshed_id));
6490 assert!(!refreshed
6491 .postings_for_trigram(pack_trigram(b'a', b'l', b'p'), None)
6492 .contains(&refreshed_id));
6493 }
6494
6495 #[test]
6496 fn grep_reports_total_matches_before_truncation() {
6497 let dir = tempfile::tempdir().expect("create temp dir");
6498 let project = dir.path().join("project");
6499 let src = project.join("src");
6500 fs::create_dir_all(&src).expect("create src dir");
6501 fs::write(src.join("main.rs"), "SearchIndex\nSearchIndex\n").expect("write src file");
6502
6503 let index = SearchIndex::build(&project);
6504 let result = index.grep("SearchIndex", true, &[], &[], &src, 1);
6505
6506 assert_eq!(result.total_matches, 2);
6507 assert_eq!(result.matches.len(), 1);
6508 assert!(result.truncated);
6509 }
6510
6511 #[test]
6512 fn glob_filters_results_to_search_root() {
6513 let dir = tempfile::tempdir().expect("create temp dir");
6514 let project = dir.path().join("project");
6515 let src = project.join("src");
6516 let scripts = project.join("scripts");
6517 fs::create_dir_all(&src).expect("create src dir");
6518 fs::create_dir_all(&scripts).expect("create scripts dir");
6519 fs::write(src.join("main.rs"), "pub fn main() {}\n").expect("write src file");
6520 fs::write(scripts.join("tool.rs"), "pub fn tool() {}\n").expect("write scripts file");
6521
6522 let index = SearchIndex::build(&project);
6523 let files = index.glob("**/*.rs", &src);
6524
6525 assert_eq!(
6526 files,
6527 vec![fs::canonicalize(src.join("main.rs")).expect("canonicalize src file")]
6528 );
6529 }
6530
6531 #[test]
6532 fn snapshot_reports_file_presence_without_a_filesystem_walk() {
6533 let dir = tempfile::tempdir().expect("create temp dir");
6534 let project = dir.path().join("project");
6535 let src = project.join("src");
6536 let empty = project.join("empty");
6537 fs::create_dir_all(&src).expect("create src dir");
6538 fs::create_dir_all(&empty).expect("create empty dir");
6539 fs::write(src.join("main.rs"), "pub fn main() {}\n").expect("write src file");
6540
6541 let index = SearchIndex::build(&project);
6542 let snapshot = index.snapshot();
6543
6544 assert!(snapshot.has_file_in_scope(&project));
6545 assert!(snapshot.has_file_in_scope(&src));
6546 assert!(!snapshot.has_file_in_scope(&empty));
6547 }
6548
6549 #[test]
6550 fn glob_includes_hidden_and_binary_files() {
6551 let dir = tempfile::tempdir().expect("create temp dir");
6552 let project = dir.path().join("project");
6553 let hidden_dir = project.join(".hidden");
6554 fs::create_dir_all(&hidden_dir).expect("create hidden dir");
6555 let hidden_file = hidden_dir.join("data.bin");
6556 fs::write(&hidden_file, [0u8, 159, 146, 150]).expect("write binary file");
6557
6558 let index = SearchIndex::build(&project);
6559 let files = index.glob("**/*.bin", &project);
6560
6561 assert_eq!(
6562 files,
6563 vec![fs::canonicalize(hidden_file).expect("canonicalize binary file")]
6564 );
6565 }
6566
6567 #[test]
6568 fn read_from_disk_rejects_invalid_nanos() {
6569 let dir = tempfile::tempdir().expect("create temp dir");
6570 let cache_dir = dir.path().join("cache");
6571 fs::create_dir_all(&cache_dir).expect("create cache dir");
6572
6573 let mut postings = Vec::new();
6574 postings.extend_from_slice(INDEX_MAGIC);
6575 postings.extend_from_slice(&INDEX_VERSION.to_le_bytes());
6576 postings.extend_from_slice(&0u32.to_le_bytes());
6577 postings.extend_from_slice(&1u32.to_le_bytes());
6578 postings.extend_from_slice(&DEFAULT_MAX_FILE_SIZE.to_le_bytes());
6579 postings.extend_from_slice(&1u32.to_le_bytes());
6580 postings.extend_from_slice(b"/");
6581 postings.push(0u8);
6582 postings.extend_from_slice(&1u32.to_le_bytes());
6583 postings.extend_from_slice(&0u64.to_le_bytes());
6584 postings.extend_from_slice(&0u64.to_le_bytes());
6585 postings.extend_from_slice(&1_000_000_000u32.to_le_bytes());
6586 postings.extend_from_slice(b"a");
6587 postings.extend_from_slice(&0u64.to_le_bytes());
6588
6589 let mut lookup = Vec::new();
6590 lookup.extend_from_slice(LOOKUP_MAGIC);
6591 lookup.extend_from_slice(&INDEX_VERSION.to_le_bytes());
6592 lookup.extend_from_slice(&0u32.to_le_bytes());
6593
6594 let postings_checksum = crc32fast::hash(&postings);
6595 postings.extend_from_slice(&postings_checksum.to_le_bytes());
6596 let lookup_checksum = crc32fast::hash(&lookup);
6597 lookup.extend_from_slice(&lookup_checksum.to_le_bytes());
6598 let mut cache = Vec::new();
6599 cache.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
6600 cache.extend_from_slice(&INDEX_VERSION.to_le_bytes());
6601 cache.extend_from_slice(&(postings.len() as u64).to_le_bytes());
6602 cache.extend_from_slice(&postings);
6603 cache.extend_from_slice(&lookup);
6604 fs::write(cache_dir.join("cache.bin"), cache).expect("write cache");
6605
6606 assert!(SearchIndex::read_from_disk(&cache_dir, dir.path()).is_none());
6607 }
6608
6609 #[test]
6610 fn parallel_cold_build_matches_serial_index() {
6611 let dir = tempfile::tempdir().expect("create temp dir");
6612 let project = dir.path().join("project");
6613 for index in 0..80 {
6614 let sub = project.join(format!("pkg_{index:03}"));
6615 fs::create_dir_all(&sub).expect("create subdir");
6616 fs::write(
6617 sub.join("lib.rs"),
6618 format!(
6619 "pub fn unique_marker_{index}() {{ println!(\"aft_perf_marker_{index}\"); }}\n"
6620 ),
6621 )
6622 .expect("write lib");
6623 }
6624
6625 let serial = SearchIndex::build_with_limit_serial(&project, DEFAULT_MAX_FILE_SIZE);
6626 let parallel = SearchIndex::build_with_limit(&project, DEFAULT_MAX_FILE_SIZE);
6627
6628 assert_eq!(serial.file_count(), parallel.file_count());
6629 assert_eq!(serial.trigram_count(), parallel.trigram_count());
6630 assert_eq!(serial.path_to_id.len(), parallel.path_to_id.len());
6631 assert_eq!(
6632 serial.file_trigram_count.as_ref(),
6633 parallel.file_trigram_count.as_ref()
6634 );
6635 for (path, id) in serial.path_to_id.iter() {
6636 assert_eq!(parallel.path_to_id.get(path), Some(id));
6637 }
6638 for (serial_file, parallel_file) in serial.files.iter().zip(parallel.files.iter()) {
6639 assert_eq!(serial_file.path, parallel_file.path);
6640 assert_eq!(serial_file.size, parallel_file.size);
6641 assert_eq!(serial_file.modified, parallel_file.modified);
6642 assert_eq!(serial_file.content_hash, parallel_file.content_hash);
6643 }
6644
6645 let serial_grep = serial.grep("aft_perf_marker_17", true, &[], &[], &project, 10);
6646 let parallel_grep = parallel.grep("aft_perf_marker_17", true, &[], &[], &project, 10);
6647 assert_eq!(serial_grep.matches, parallel_grep.matches);
6648 assert_eq!(serial_grep.total_matches, parallel_grep.total_matches);
6649 assert_eq!(serial_grep.files_searched, parallel_grep.files_searched);
6650 assert_eq!(
6651 serial_grep.files_with_matches,
6652 parallel_grep.files_with_matches
6653 );
6654 }
6655
6656 #[test]
6657 fn ignore_rule_discovery_respects_gitignore() {
6658 let _git_env = crate::test_env::hermetic_git_env_guard();
6659 let dir = tempfile::tempdir().expect("create temp dir");
6660 let project = dir.path().join("project");
6661 fs::create_dir_all(project.join("src")).expect("mkdir src");
6662 fs::write(project.join("src/.gitignore"), "data/\n").expect("write gitignore");
6663 let data = project.join("src/data");
6664 fs::create_dir_all(&data).expect("mkdir data");
6665 for index in 0..200 {
6666 fs::create_dir_all(data.join(format!("d{index}"))).expect("mkdir nested");
6667 fs::write(data.join(format!("d{index}/f.rs")), "fn ignored() {}\n")
6668 .expect("write ignored file");
6669 }
6670
6671 let mut init = Command::new("git");
6672 crate::test_env::apply_hermetic_git_env(init.arg("init").arg(&project))
6673 .status()
6674 .expect("git init");
6675 for args in [
6676 ["config", "user.email", "aft@example.invalid"],
6677 ["config", "user.name", "AFT Test"],
6678 ] {
6679 git_command_for_test(&project)
6680 .args(args)
6681 .status()
6682 .expect("git config");
6683 }
6684 git_command_for_test(&project)
6685 .args(["add", "."])
6686 .status()
6687 .expect("git add");
6688 git_command_for_test(&project)
6689 .args(["commit", "-m", "initial"])
6690 .status()
6691 .expect("git commit");
6692
6693 let legacy_dirs = count_ignore_rule_discovery_dirs_legacy_stack(&project);
6694 let walker_dirs = count_ignore_rule_discovery_dirs(&project);
6695 assert!(
6696 legacy_dirs > walker_dirs,
6697 "legacy stack should descend into gitignored data/ (legacy={legacy_dirs}, walker={walker_dirs})"
6698 );
6699 assert!(
6700 walker_dirs < 50,
6701 "ignore walker should not descend deeply into ignored tree (dirs={walker_dirs})"
6702 );
6703 }
6704
6705 #[test]
6720 fn sort_paths_by_mtime_desc_does_not_panic_on_missing_files() {
6721 let dir = tempfile::tempdir().expect("create tempdir");
6725 let mut paths: Vec<PathBuf> = Vec::new();
6726 for i in 0..30 {
6727 let path = if i % 2 == 0 {
6729 let p = dir.path().join(format!("real-{i}.rs"));
6730 fs::write(&p, format!("// {i}\n")).expect("write");
6731 p
6732 } else {
6733 dir.path().join(format!("missing-{i}.rs"))
6734 };
6735 paths.push(path);
6736 }
6737
6738 for _ in 0..50 {
6741 let mut copy = paths.clone();
6742 sort_paths_by_mtime_desc(&mut copy);
6743 assert_eq!(copy.len(), paths.len());
6744 }
6745 }
6746
6747 #[test]
6753 fn uncapped_indexed_grep_over_many_files_is_not_engine_capped() {
6754 let dir = tempfile::tempdir().expect("create tempdir");
6755 for i in 0..40 {
6758 fs::write(
6759 dir.path().join(format!("file-{i}.rs")),
6760 format!("fn unique_marker_{i}() {{ let _ = \"needle_token\"; }}\n"),
6761 )
6762 .expect("write");
6763 }
6764 let index = SearchIndex::build_with_limit(dir.path(), DEFAULT_MAX_FILE_SIZE);
6765 let result = index.grep("needle_token", false, &[], &[], dir.path(), 1000);
6766 assert!(
6767 result.matches.len() >= 40,
6768 "expected a match per file, got {}",
6769 result.matches.len()
6770 );
6771 assert!(
6772 !result.engine_capped,
6773 "an uncapped grep over >10 files must not report engine_capped"
6774 );
6775 assert!(!result.truncated, "uncapped grep must not be truncated");
6776 }
6777
6778 #[test]
6782 fn sort_grep_matches_by_mtime_desc_does_not_panic_on_missing_files() {
6783 let dir = tempfile::tempdir().expect("create tempdir");
6784 let mut matches: Vec<GrepMatch> = Vec::new();
6785 for i in 0..30 {
6786 let file = if i % 2 == 0 {
6787 let p = dir.path().join(format!("real-{i}.rs"));
6788 fs::write(&p, format!("// {i}\n")).expect("write");
6789 p
6790 } else {
6791 dir.path().join(format!("missing-{i}.rs"))
6792 };
6793 matches.push(GrepMatch {
6794 file,
6795 line: u32::try_from(i).unwrap_or(0),
6796 column: 0,
6797 line_text: format!("match {i}"),
6798 match_text: format!("match {i}"),
6799 });
6800 }
6801
6802 for _ in 0..50 {
6803 let mut copy = matches.clone();
6804 sort_grep_matches_by_mtime_desc(&mut copy, dir.path());
6805 assert_eq!(copy.len(), matches.len());
6806 }
6807 }
6808}
6809
6810#[cfg(test)]
6811mod warm_reload_verification_tests {
6812 use super::*;
6813
6814 #[test]
6815 fn warm_disk_verification_uses_stat_first_and_hashes_changed_stats() {
6816 let dir = tempfile::tempdir().unwrap();
6817 let root = fs::canonicalize(dir.path()).unwrap();
6818 let path = root.join("warm.rs");
6819 fs::write(&path, "fn warm_reload() {}\n").unwrap();
6820 let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
6821 filetime::set_file_mtime(&path, original_mtime).unwrap();
6822 let mut index = SearchIndex::build(&root);
6823
6824 cache_freshness::watch_hash_file_for_debug(&path);
6825 index.verify_against_disk_with_strategy(None, cache_freshness::VerifyStrategy::StatFirst);
6826 assert_eq!(cache_freshness::watched_hash_file_count_for_debug(), 0);
6827
6828 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
6829 cache_freshness::watch_hash_file_for_debug(&path);
6830 index.verify_against_disk_with_strategy(None, cache_freshness::VerifyStrategy::StatFirst);
6831 assert_eq!(cache_freshness::watched_hash_file_count_for_debug(), 1);
6832 }
6833
6834 #[test]
6835 fn write_denied_cold_build_flags_build_denied_instead_of_building() {
6836 let project = tempfile::tempdir().expect("project");
6842 let source = project.path().join("lib.rs");
6843 fs::write(&source, "pub fn answer() -> i32 { 42 }\n").expect("write source");
6844 let project_key = "shared-search-artifact".to_string();
6845 crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
6846
6847 let storage = tempfile::tempdir().expect("storage");
6849 let cache_dir = storage.path().join(&project_key);
6850 let index = SearchIndex::build_with_limit_to_cache_dir(
6851 project.path(),
6852 DEFAULT_MAX_FILE_SIZE,
6853 &cache_dir,
6854 );
6855
6856 assert!(
6857 index.build_denied,
6858 "a write-denied cold build must flag build_denied so health can report a settled state"
6859 );
6860 assert!(
6861 !index.ready,
6862 "build_denied must keep ready=false so grep/glob keep using the bounded fallback walk"
6863 );
6864 assert!(
6865 index.files.is_empty(),
6866 "a write-denied build must not materialize an in-RAM index"
6867 );
6868 }
6869}
6870
6871#[cfg(test)]
6872mod interactive_artifact_read_budget_tests {
6873 use super::*;
6874 use std::sync::Arc;
6875 use std::thread;
6876
6877 #[test]
6878 fn contended_read_returns_within_interactive_budget() {
6879 let lock = Arc::new(RwLock::new(()));
6880 let writer = lock.write().expect("acquire test writer");
6881 let reader_lock = Arc::clone(&lock);
6882 let reader = thread::spawn(move || {
6883 let started = Instant::now();
6884 let result = try_read_with_budget(&reader_lock, Duration::from_millis(20));
6885 (result.is_some(), started.elapsed())
6886 });
6887
6888 thread::sleep(Duration::from_millis(75));
6889 drop(writer);
6890 let (acquired, elapsed) = reader.join().expect("join bounded reader");
6891 assert!(!acquired, "a live writer must force bounded degradation");
6892 assert!(
6893 elapsed < Duration::from_millis(60),
6894 "contended read exceeded its 20ms budget: {elapsed:?}"
6895 );
6896 }
6897}