Skip to main content

aft/
search_index.rs

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