Skip to main content

aft/
search_index.rs

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