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