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