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