Skip to main content

aft/
search_index.rs

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