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