Skip to main content

aft/
search_index.rs

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