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