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