Skip to main content

aft/
search_index.rs

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