Skip to main content

aft/
search_index.rs

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