Skip to main content

aft/
search_index.rs

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; // "XID1" little-endian
26const 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        // Graceful shutdown gets one short best-effort lock attempt so a
99        // sibling writer cannot hold process exit open.
100        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    /// Number of indexed files.
200    pub fn file_count(&self) -> usize {
201        self.files.len()
202    }
203
204    /// Number of unique trigrams in the combined base index and delta postings.
205    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    /// Estimate resident trigram-index bytes. Base posting lists stay on disk
219    /// and are read with `pread`; only the resident base lookup table, delta
220    /// postings, superseded mask, and file tables are included here.
221    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    /// True when `write_to_disk` would persist changes beyond the current base.
342    /// This covers pure deletions and unindexed file additions, which do not
343    /// always populate `delta_file_trigrams`.
344    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    /// Returns an immutable snapshot for queries. Callers must obtain the
351    /// snapshot while holding the RwLock that protects the SearchIndex, then
352    /// drop the guard before running expensive operations such as grep, glob, or
353    /// lexical ranking.
354    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    /// Compute distinct query trigrams from literal tokens.
369    pub fn query_trigrams_from_tokens(tokens: &[&str]) -> Vec<u32> {
370        query_trigrams_from_tokens(tokens)
371    }
372
373    /// Score-rank file candidates by lexical relevance to query trigrams.
374    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    /// Score-rank file candidates and report whether the pre-filter step that
386    /// collects candidates reached its internal size limit before ranking.
387    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    /// Number of unique trigrams in the combined base index and delta postings.
400    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    /// Score-rank file candidates and report whether the pre-filter step that
421    /// collects candidates reached its internal size limit before ranking.
422    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        // Candidate discovery needs only the three rarest trigrams, while scoring
448        // needs every query trigram. Materialize all lists once per query so both
449        // phases reuse the same disk-backed postings instead of rereading them for
450        // every candidate. Memory remains bounded by the query's posting lists.
451        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    /// True when a fallback directory walk stopped early due to file-count or time budget.
529    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    /// Serial cold build for tests and parity checks against [`build_with_limit`].
719    #[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                // HEAD matches, but files may have changed on disk since the index was
1354                // last written (e.g., uncommitted edits, stash pop, manual file changes
1355                // while OpenCode was closed). Verify mtimes and re-index stale files.
1356                // Non-git projects also use this per-file (path, mtime, size)
1357                // fingerprint so unchanged trees reuse the disk cache instead of
1358                // rebuilding every configure.
1359                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, &current) {
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                    // When concatenating partial match lists from parallel file
1723                    // searches, simply append the chunks. The stop checks in
1724                    // each worker decide whether the result cap was reached.
1725                    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(&current, &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(&current, &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        // next_mask is a bloom filter: the character following this trigram in
2007        // the query must also appear after this trigram somewhere in the file.
2008        if filter.next_mask != 0 && posting.next_mask & filter.next_mask == 0 {
2009            return false;
2010        }
2011        // loc_mask is persisted for future adjacency checks. It is intentionally
2012        // not used as a single-trigram filter because query positions do not
2013        // correspond to file positions.
2014    }
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    // Defense in depth: even though indexing tries to filter binaries via
2042    // `is_binary_path` + full-content `is_binary_bytes`, we double-check at
2043    // query time. content_inspector is fast (~bytes-per-cycle on a small
2044    // preview) and this guarantees we never surface matches inside binary
2045    // files even if the indexer somehow let one through (e.g. file changed
2046    // between indexing and query).
2047    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
2249/// Returns the worker pool size for cold search-index builds: half of available
2250/// cores, capped at 8 to keep the same limit used by the callgraph store.
2251fn 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
3617/// Sort paths newest-first by mtime, falling back to normalized display-path order.
3618///
3619/// Pre-v0.15.2 this called `path_modified_time(...)` directly inside the
3620/// `sort_by()` closure. That made the comparator non-deterministic — a
3621/// `stat()` syscall for the same path can return different values across
3622/// invocations (file edited mid-sort, file deleted, OS clock adjustments,
3623/// concurrent file-watcher activity), and Rust's slice::sort panics at
3624/// runtime when it detects a non-total-order comparator. CI hit this on
3625/// a Pi e2e test where the bridge invalidated files in parallel with grep.
3626///
3627/// Fix: snapshot mtimes ONCE into a HashMap before sorting, then look up
3628/// from the map inside the closure. Pure function ⇒ guaranteed total order.
3629pub(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
3658/// See `sort_paths_by_mtime_desc` for why mtimes are snapshotted ahead of
3659/// the sort. Same fix, applied to grep matches that share files.
3660pub(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        // The display-path tiebreak makes complete result sets deterministic.
3685        // If a parallel grep stops early after hitting a cap, the capped subset
3686        // can still depend on which worker reaches the cap first.
3687        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
3695/// See `sort_paths_by_mtime_desc` for why mtimes are snapshotted ahead of
3696/// the sort. The cached lookup function `modified_for_path` is fast (in-memory
3697/// table from the search index), but it can still return different values if
3698/// the file is modified mid-sort. Snapshot once.
3699fn 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        // The display-path tiebreak makes complete result sets deterministic.
3730        // If a parallel grep stops early after hitting a cap, the capped subset
3731        // can still depend on which worker reaches the cap first.
3732        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
4058/// Resolve the repository root commit, retrying transient git failures.
4059///
4060/// The distinction matters because the fallback is not benign: two clones of
4061/// one repo that key differently (one by commit, one by path) each claim
4062/// artifact ownership and write the shared cache concurrently. A git
4063/// invocation that fails under load (spawn failure, resource exhaustion) must
4064/// therefore be retried, and callers that need stable identity can refuse path
4065/// fallback when the result is still ambiguous after retry.
4066fn 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    /// Deterministic: not a git work tree.
4092    NotARepo,
4093    /// Deterministic but still git-like: a repository exists but has no commit identity yet.
4094    NoCommit,
4095    /// Ambiguous failure (spawn error, killed, unexpected git error): retry.
4096    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
4216/// Fingerprint corpus-shaping ignore rules that are not represented by git HEAD.
4217///
4218/// The search cache stores this value next to the file mtimes. If `.gitignore`,
4219/// `.aftignore`, or `.git/info/exclude` changes while AFT is not running, a
4220/// matching HEAD + matching file mtimes is not enough to safely reuse the old
4221/// cache: files that are now ignored may still be indexed. Hashing the ignore
4222/// files themselves makes cold-start cache reuse agree with the current walker.
4223pub 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/// Count directories visited when discovering ignore rule files (for perf regression tests).
4313#[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/// Legacy stack-based discovery (pre ignore-walker fix); used only in perf tests.
4332#[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
4452/// Verify stored file mtimes against disk. Re-index any files whose mtime changed
4453/// since the index was last written. Also detect new files and deleted files.
4454fn 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; // tombstoned entry
4468        }
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    // Re-index stale files that are still in the current walk set. If an ignore
4508    // rule changed while AFT was down but the fingerprint missed it, this keeps
4509    // warm-cache verification from resurrecting now-ignored cached entries.
4510    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    // Detect new files not in the index
4520    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        // Same repo (same root commit) → same cache key regardless of clone path
5701        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        // Index stores canonicalized paths; on macOS /var → /private/var
6023        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    /// Regression: v0.15.2 — sort_paths_by_mtime_desc panicked when files
6309    /// changed between cmp() calls.
6310    ///
6311    /// Pre-fix, the sort closure called `path_modified_time(path)` directly,
6312    /// which does a `stat()` syscall. If the file was deleted, modified, or
6313    /// touched mid-sort, the comparator returned different values for the
6314    /// same input pair on different invocations. Rust's slice::sort detects
6315    /// this and panics with "user-provided comparison function does not
6316    /// correctly implement a total order".
6317    ///
6318    /// CI hit this on a Pi e2e test (workflow run 24887807972) where the
6319    /// bridge invalidated files in parallel with grep's sort path. This
6320    /// test simulates the worst case: most paths don't exist (Err from
6321    /// fs::metadata) and sort still completes successfully.
6322    #[test]
6323    fn sort_paths_by_mtime_desc_does_not_panic_on_missing_files() {
6324        // Mix of existing and non-existing paths in deliberately
6325        // non-monotonic order — pre-fix, the sort would call stat() at
6326        // least N log N times and any flakiness would trigger the panic.
6327        let dir = tempfile::tempdir().expect("create tempdir");
6328        let mut paths: Vec<PathBuf> = Vec::new();
6329        for i in 0..30 {
6330            // Half exist, half don't.
6331            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        // Run the sort many times to maximise the chance of catching any
6342        // residual non-determinism. Pre-fix: panic. Post-fix: stable.
6343        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    /// Regression: the indexed parallel search's reduce() combine closure must
6351    /// NOT set engine_capped. reduce runs on every partial-result merge in a
6352    /// multi-chunk parallel search (>10 candidate files), capped or not — an
6353    /// unconditional store there falsely reported every such grep as capped,
6354    /// lying to the agent that results were truncated.
6355    #[test]
6356    fn uncapped_indexed_grep_over_many_files_is_not_engine_capped() {
6357        let dir = tempfile::tempdir().expect("create tempdir");
6358        // >10 files so the parallel (reduce) branch is taken, each with exactly
6359        // one match, and a generous cap so the search is NOT actually capped.
6360        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    /// Regression: v0.15.2 — sort_grep_matches_by_mtime_desc panicked under
6382    /// the same conditions as sort_paths_by_mtime_desc. See the
6383    /// sort_paths_... test above for the full rationale.
6384    #[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}