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