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