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