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