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