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