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