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