Skip to main content

vtcode_indexer/
file_search.rs

1#![expect(
2    unused_results,
3    reason = "File-search walker and heap operations intentionally use fluent/configuration mutation for side effects."
4)]
5
6//! Fast fuzzy file search library for VT Code.
7//!
8//! Uses the `ignore` crate (same as ripgrep) for parallel directory traversal
9//! and `nucleo-matcher` for fuzzy matching.
10//!
11//! # Example
12//!
13//! ```ignore
14//! use std::num::NonZero;
15//! use std::path::Path;
16//! use std::sync::Arc;
17//! use std::sync::atomic::AtomicBool;
18//! use vtcode_indexer::file_search::run;
19//!
20//! let results = run(
21//!     "main",
22//!     NonZero::new(100).unwrap(),
23//!     Path::new("."),
24//!     vec![],
25//!     NonZero::new(4).unwrap(),
26//!     Arc::new(AtomicBool::new(false)),
27//!     false,
28//!     true,
29//! )?;
30//!
31//! for m in results.matches {
32//!     println!("{}: {}", m.path, m.score);
33//! }
34//! # Ok::<(), anyhow::Error>(())
35//! ```
36
37use arc_swap::ArcSwapOption;
38use parking_lot::Mutex;
39use serde::{Deserialize, Serialize};
40use std::cmp::Reverse;
41use std::collections::BinaryHeap;
42use std::num::NonZero;
43use std::path::Path;
44use std::sync::Arc;
45use std::sync::atomic::{AtomicBool, Ordering};
46use tokio::sync::RwLock;
47
48use rayon::prelude::*;
49use vtcode_commons::StringId;
50
51/// Pre-computed file index for instant queries.
52///
53/// This index is built in the background and cached to avoid
54/// repeated directory traversals on every search.
55pub struct FileIndex {
56    files: Vec<StringId>,
57    directories: Vec<StringId>,
58    /// Immutable path text indexed by the corresponding [`StringId`].
59    /// Searches only read this table, so scoring never contends on the
60    /// incremental-update interner or allocates a candidate string.
61    path_texts_by_id: Arc<Vec<Arc<str>>>,
62    /// Used only while applying incremental index updates.
63    interner: vtcode_commons::StringInterner,
64    last_built: std::time::Instant,
65}
66
67/// Build a parallel walker with the given configuration.
68fn build_parallel_walker(
69    search_directory: &Path,
70    exclude: &[String],
71    threads: usize,
72    respect_gitignore: bool,
73    follow_links: bool,
74) -> anyhow::Result<ignore::WalkParallel> {
75    let mut walk_builder = ignore::WalkBuilder::new(search_directory);
76    vtcode_commons::walk::apply_defaults(&mut walk_builder);
77
78    // File-search-specific overrides
79    walk_builder.threads(threads);
80    walk_builder.follow_links(follow_links);
81    walk_builder.require_git(false); // Search works outside git repos
82
83    if !respect_gitignore {
84        walk_builder
85            .git_ignore(false)
86            .git_global(false)
87            .git_exclude(false)
88            .ignore(false)
89            .parents(false);
90    }
91
92    if !exclude.is_empty() {
93        let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
94        for exclude_pattern in exclude {
95            let pattern = format!("!{exclude_pattern}");
96            override_builder.add(&pattern)?;
97        }
98        walk_builder.overrides(override_builder.build()?);
99    }
100
101    Ok(walk_builder.build_parallel())
102}
103
104impl FileIndex {
105    /// Build a file index by traversing the directory tree.
106    /// This is expensive but only done once.
107    fn build_from_directory(
108        search_directory: &Path,
109        exclude: &[String],
110        respect_gitignore: bool,
111        threads: usize,
112    ) -> anyhow::Result<Self> {
113        let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, true)?;
114
115        // Collect all files and directories
116        let worker_results = Arc::new(Mutex::new(Vec::new()));
117
118        walker.run(|| {
119            let search_dir = search_directory.to_path_buf();
120            let mut state = IndexWorkerGuard {
121                results: Arc::clone(&worker_results),
122                files: Vec::new(),
123                directories: Vec::new(),
124            };
125
126            Box::new(move |result| {
127                let entry = match result {
128                    Ok(e) => e,
129                    Err(_) => return ignore::WalkState::Continue,
130                };
131
132                // Make path relative to search directory
133                if let Some(rel_path) = entry.path().strip_prefix(&search_dir).ok().and_then(|p| p.to_str())
134                    && !rel_path.is_empty()
135                {
136                    if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
137                        state.directories.push(rel_path.to_string());
138                    } else {
139                        state.files.push(rel_path.to_string());
140                    }
141                }
142
143                ignore::WalkState::Continue
144            })
145        });
146
147        let worker_results = Arc::try_unwrap(worker_results)
148            .map_err(|results| {
149                anyhow::anyhow!("file index worker results still referenced: {}", Arc::strong_count(&results))
150            })?
151            .into_inner();
152        let (files, directories) =
153            worker_results
154                .into_iter()
155                .fold((Vec::new(), Vec::new()), |(mut files, mut directories), result| {
156                    files.extend(result.files);
157                    directories.extend(result.directories);
158                    (files, directories)
159                });
160
161        let mut interner = vtcode_commons::StringInterner::new();
162        let mut path_texts_by_id = Vec::with_capacity(files.len() + directories.len());
163        let interned_files: Vec<StringId> = files
164            .iter()
165            .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
166            .collect();
167        let interned_dirs: Vec<StringId> = directories
168            .iter()
169            .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
170            .collect();
171
172        Ok(Self {
173            files: interned_files,
174            directories: interned_dirs,
175            path_texts_by_id: Arc::new(path_texts_by_id),
176            interner,
177            last_built: std::time::Instant::now(),
178        })
179    }
180
181    /// Query the index for matching paths.
182    /// Much faster than re-traversing the filesystem.
183    fn query(
184        &self,
185        pattern_text: &str,
186        limit: usize,
187        match_type_filter: Option<MatchType>,
188    ) -> Vec<(u32, StringId, MatchType)> {
189        // `query` stays serial and declarative: the parallel scoring strategy
190        // is isolated behind `score_paths_top_k`, and the per-chunk top-K heaps
191        // are merged by the shared `merge_top_k` helper. This keeps the index
192        // query logic testable without a rayon runtime in the loop.
193        let mut heaps = Vec::new();
194
195        if match_type_filter.is_none_or(|t| t == MatchType::File) {
196            heaps.push(score_paths_top_k(
197                &self.files,
198                self.path_texts_by_id.as_slice(),
199                limit,
200                pattern_text,
201                MatchType::File,
202            ));
203        }
204
205        if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
206            heaps.push(score_paths_top_k(
207                &self.directories,
208                self.path_texts_by_id.as_slice(),
209                limit,
210                pattern_text,
211                MatchType::Directory,
212            ));
213        }
214
215        merge_top_k(heaps, limit)
216            .into_sorted_vec()
217            .into_iter()
218            .map(|Reverse(item)| item)
219            .collect()
220    }
221}
222
223fn intern_path(
224    path: &str,
225    interner: &mut vtcode_commons::StringInterner,
226    path_texts_by_id: &mut Vec<Arc<str>>,
227) -> StringId {
228    let path_id = interner.intern(path);
229    let path_index = path_id.as_u32() as usize;
230    if path_index == path_texts_by_id.len() {
231        path_texts_by_id.push(Arc::from(path));
232    }
233    path_id
234}
235
236/// Score `paths` in parallel rayon chunks, returning the worker-merged top-K
237/// heap for `match_type`.
238///
239/// This is the single boundary for the parallel scoring strategy: each worker
240/// thread gets its own `BestMatchesList` (matcher + haystack buffer reused via
241/// `map_init`), keeps its own top-K heap, and the partial heaps are merged by
242/// `merge_top_k`. Callers must not depend on equal-score ordering.
243fn score_paths_top_k(
244    paths: &[StringId],
245    path_texts_by_id: &[Arc<str>],
246    limit: usize,
247    pattern_text: &str,
248    match_type: MatchType,
249) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
250    const CHUNK: usize = 1024;
251
252    // Serial fast path for small inputs: avoids the rayon thread-pool spawn
253    // overhead and keeps equal-score ordering deterministic.
254    if paths.len() <= CHUNK {
255        let mut list = BestMatchesList::new(limit, pattern_text);
256        for &path_id in paths {
257            if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
258                list.record_match(path_id, path, match_type);
259            }
260        }
261        return list.matches;
262    }
263
264    let heaps: Vec<_> = paths
265        .par_chunks(CHUNK)
266        .map_init(
267            || BestMatchesList::new(limit, pattern_text),
268            |list, chunk| {
269                for &path_id in chunk {
270                    if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
271                        list.record_match(path_id, path, match_type);
272                    }
273                }
274                std::mem::take(&mut list.matches)
275            },
276        )
277        .collect();
278
279    merge_top_k(heaps, limit)
280}
281
282/// Merge worker-local top-K heaps into a single top-K heap.
283///
284/// Because each input heap already holds only its own highest-scoring `limit`
285/// entries, the global top-K is a subset of their union; merging and re-keeping
286/// the top-K yields the correct global result.
287fn merge_top_k(
288    heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>>,
289    limit: usize,
290) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
291    let mut merged = BinaryHeap::with_capacity(limit);
292    for heap in heaps {
293        for Reverse(item) in heap.into_vec() {
294            push_top_match(&mut merged, limit, item.0, item.1, item.2);
295        }
296    }
297    merged
298}
299
300/// A cached file index that can be shared across searches.
301pub struct FileIndexCache {
302    cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
303    /// Lock-free copy of the latest published index for synchronous callers.
304    /// This keeps `refresh_background` from blocking or panicking when called
305    /// on a Tokio worker that is concurrently publishing a replacement.
306    snapshot: Arc<ArcSwapOption<FileIndex>>,
307    /// Serializes full index builds so concurrent cache misses do not launch
308    /// duplicate workspace traversals and Rayon jobs.
309    build_gate: Arc<tokio::sync::Semaphore>,
310    search_directory: std::path::PathBuf,
311    exclude: Vec<String>,
312    respect_gitignore: bool,
313    threads: usize,
314}
315
316impl FileIndexCache {
317    pub fn new(
318        search_directory: std::path::PathBuf,
319        exclude: impl IntoIterator<Item = String>,
320        respect_gitignore: bool,
321        threads: usize,
322    ) -> Self {
323        Self {
324            cache: Arc::new(RwLock::new(None)),
325            snapshot: Arc::new(ArcSwapOption::empty()),
326            build_gate: Arc::new(tokio::sync::Semaphore::new(1)),
327            search_directory,
328            exclude: exclude.into_iter().collect(),
329            respect_gitignore,
330            threads,
331        }
332    }
333
334    /// Get or build the file index.
335    pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
336        // Check if we have a cached index
337        {
338            let guard = self.cache.read().await;
339            if let Some(index) = guard.as_ref() {
340                // Check if index is stale (older than 5 minutes)
341                if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
342                    return Ok(Arc::clone(index));
343                }
344            }
345        }
346
347        // Re-check after waiting for another caller's build. This avoids a
348        // cache-stampede when several searches arrive on an empty/stale cache.
349        let _build_permit = self.build_gate.acquire().await?;
350        {
351            let guard = self.cache.read().await;
352            if let Some(index) = guard.as_ref()
353                && index.last_built.elapsed() < std::time::Duration::from_secs(300)
354            {
355                return Ok(Arc::clone(index));
356            }
357        }
358
359        // Directory traversal and index construction are synchronous and can
360        // touch a large workspace. Keep that work off the Tokio worker so a
361        // cache miss cannot delay unrelated async tasks.
362        let search_directory = self.search_directory.clone();
363        let exclude = self.exclude.clone();
364        let respect_gitignore = self.respect_gitignore;
365        let threads = self.threads;
366        let index = Arc::new(
367            tokio::task::spawn_blocking(move || {
368                FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
369            })
370            .await??,
371        );
372
373        // Cache and return
374        {
375            let mut guard = self.cache.write().await;
376            *guard = Some(Arc::clone(&index));
377            self.snapshot.store(Some(Arc::clone(&index)));
378        }
379        Ok(index)
380    }
381
382    /// Force refresh the index in the background.
383    ///
384    /// Returns the latest published index immediately while rebuilding happens
385    /// asynchronously. If no Tokio runtime is available, no refresh is
386    /// scheduled and the latest published index is returned unchanged.
387    pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
388        let runtime = match tokio::runtime::Handle::try_current() {
389            Ok(runtime) => runtime,
390            Err(error) => {
391                tracing::debug!(%error, "cannot refresh file index without a Tokio runtime");
392                return self.snapshot.load_full();
393            }
394        };
395
396        // Build new index asynchronously
397        let search_directory = self.search_directory.clone();
398        let exclude = self.exclude.clone();
399        let respect_gitignore = self.respect_gitignore;
400        let threads = self.threads;
401        let cache = self.cache.clone();
402        let snapshot = Arc::clone(&self.snapshot);
403        let build_gate = Arc::clone(&self.build_gate);
404
405        runtime.spawn(async move {
406            let _build_permit = match build_gate.acquire_owned().await {
407                Ok(permit) => permit,
408                Err(error) => {
409                    tracing::error!(%error, "file index build gate closed");
410                    return;
411                }
412            };
413
414            match tokio::task::spawn_blocking(move || {
415                FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
416            })
417            .await
418            {
419                Ok(Ok(new_index)) => {
420                    let new_index = Arc::new(new_index);
421                    let mut guard = cache.write().await;
422                    *guard = Some(Arc::clone(&new_index));
423                    snapshot.store(Some(new_index));
424                }
425                Ok(Err(error)) => {
426                    tracing::error!(%error, "failed to rebuild file index");
427                }
428                Err(error) => {
429                    tracing::error!(%error, "file index rebuild task failed");
430                }
431            }
432        });
433
434        self.snapshot.load_full()
435    }
436
437    /// Incrementally update the index when a file change is detected.
438    /// This is faster than a full rebuild for single file changes.
439    pub fn update_file(&self, path: &str, is_added: bool) {
440        let mut guard = self.cache.blocking_write();
441        let Some(existing) = guard.take() else { return };
442
443        let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
444        if is_added {
445            let path_id = intern_path(path, &mut index.interner, Arc::make_mut(&mut index.path_texts_by_id));
446            let is_directory = self.search_directory.join(path).is_dir();
447            if is_directory {
448                index.files.retain(|&existing| existing != path_id);
449                if !index.directories.contains(&path_id) {
450                    index.directories.push(path_id);
451                }
452            } else {
453                index.directories.retain(|&existing| existing != path_id);
454                if !index.files.contains(&path_id) {
455                    index.files.push(path_id);
456                }
457            }
458        } else {
459            let Some(path_id) = index.files.iter().chain(index.directories.iter()).copied().find(|&path_id| {
460                index
461                    .path_texts_by_id
462                    .get(path_id.as_u32() as usize)
463                    .is_some_and(|value| value.as_ref() == path)
464            }) else {
465                let index = Arc::new(index);
466                *guard = Some(Arc::clone(&index));
467                self.snapshot.store(Some(index));
468                return;
469            };
470            index.files.retain(|&existing| existing != path_id);
471            index.directories.retain(|&existing| existing != path_id);
472        }
473        index.last_built = std::time::Instant::now();
474        let index = Arc::new(index);
475        *guard = Some(Arc::clone(&index));
476        self.snapshot.store(Some(index));
477    }
478
479    /// Get the age of the current index.
480    pub async fn index_age(&self) -> Option<std::time::Duration> {
481        let guard = self.cache.read().await;
482        guard.as_ref().map(|idx| idx.last_built.elapsed())
483    }
484}
485
486// Make FileIndex cloneable
487impl Clone for FileIndex {
488    fn clone(&self) -> Self {
489        Self {
490            files: self.files.clone(),
491            directories: self.directories.clone(),
492            path_texts_by_id: Arc::clone(&self.path_texts_by_id),
493            interner: self.interner.clone(),
494            last_built: self.last_built,
495        }
496    }
497}
498
499/// A single file match result.
500///
501/// Fields:
502/// - `score`: Relevance score from fuzzy matching (higher is better)
503/// - `path`: Path relative to the search directory
504/// - `match_type`: Whether the match is a file or directory
505/// - `indices`: Optional character positions for highlighting matched characters
506#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
507#[serde(rename_all = "lowercase")]
508pub enum MatchType {
509    File,
510    Directory,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct FileMatch {
515    pub score: u32,
516    pub path: String,
517    pub match_type: MatchType,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub indices: Option<Vec<u32>>,
520}
521
522/// Complete search results with total match count.
523#[derive(Debug)]
524pub struct FileSearchResults {
525    pub matches: Vec<FileMatch>,
526    pub total_match_count: usize,
527}
528
529/// Configuration for file search operations.
530pub struct FileSearchConfig {
531    pub pattern_text: String,
532    pub limit: NonZero<usize>,
533    pub search_directory: std::path::PathBuf,
534    pub exclude: Vec<String>,
535    pub threads: NonZero<usize>,
536    pub cancel_flag: Arc<AtomicBool>,
537    pub compute_indices: bool,
538    pub respect_gitignore: bool,
539}
540
541pub use vtcode_commons::paths::file_name_from_path;
542
543/// Best matches list per worker thread (lock-free collection).
544///
545/// Each worker thread gets its own instance to avoid locking during
546/// directory traversal. Results are merged at the end.
547struct BestMatchesList {
548    matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
549    limit: usize,
550    matcher: nucleo_matcher::Matcher,
551    haystack_buf: Vec<char>,
552    /// Pre-computed pattern - avoids per-match UTF-32 conversion
553    pattern: PatternStorage,
554    owned_matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
555}
556
557#[derive(Debug, Eq, PartialEq)]
558struct OwnedMatchCandidate {
559    score: u32,
560    path: String,
561    match_type: MatchType,
562}
563
564impl Ord for OwnedMatchCandidate {
565    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
566        self.score
567            .cmp(&other.score)
568            .then_with(|| other.path.cmp(&self.path))
569            .then_with(|| other.match_type.cmp(&self.match_type))
570    }
571}
572
573impl PartialOrd for OwnedMatchCandidate {
574    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
575        Some(self.cmp(other))
576    }
577}
578
579struct SearchAggregation {
580    matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
581    total_match_count: usize,
582}
583
584struct IndexWorkerResult {
585    files: Vec<String>,
586    directories: Vec<String>,
587}
588
589struct IndexWorkerGuard {
590    results: Arc<Mutex<Vec<IndexWorkerResult>>>,
591    files: Vec<String>,
592    directories: Vec<String>,
593}
594
595impl Drop for IndexWorkerGuard {
596    fn drop(&mut self) {
597        self.results.lock().push(IndexWorkerResult {
598            files: std::mem::take(&mut self.files),
599            directories: std::mem::take(&mut self.directories),
600        });
601    }
602}
603
604struct SearchWorkerGuard {
605    results: Arc<Mutex<SearchAggregation>>,
606    best_list: BestMatchesList,
607    total_match_count: usize,
608    limit: usize,
609}
610
611impl Drop for SearchWorkerGuard {
612    fn drop(&mut self) {
613        let worker_matches = std::mem::take(&mut self.best_list.owned_matches);
614        let mut results = self.results.lock();
615        results.total_match_count += self.total_match_count;
616        for Reverse(candidate) in worker_matches {
617            push_owned_match(&mut results.matches, self.limit, candidate);
618        }
619    }
620}
621
622/// Stores a pattern in the optimal form for Utf32Str creation.
623enum PatternStorage {
624    /// ASCII pattern - can be used directly with Utf32Str::Ascii
625    Ascii(Vec<u8>),
626    /// Unicode pattern - stored as chars for Utf32Str::Unicode
627    Unicode(Vec<char>),
628}
629
630impl BestMatchesList {
631    fn new(limit: usize, pattern_text: &str) -> Self {
632        // Normalize pattern to lowercase to work around a nucleo-matcher bug:
633        // its prefilter only does case-insensitive search for lowercase needle
634        // chars, not uppercase. See https://github.com/openai/codex/pull/15772.
635        let pattern = if pattern_text.is_ascii() {
636            PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
637        } else {
638            PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
639        };
640
641        Self {
642            matches: BinaryHeap::new(),
643            limit,
644            matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
645            haystack_buf: Vec::with_capacity(256),
646            pattern,
647            owned_matches: BinaryHeap::new(),
648        }
649    }
650
651    /// Score a path using the pre-computed pattern without allocating a
652    /// temporary string.
653    fn score_path(&mut self, path: &str) -> Option<u32> {
654        let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
655        let needle = match &self.pattern {
656            PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
657            PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
658        };
659        self.matcher.fuzzy_match(haystack, needle).map(|score| score as u32)
660    }
661
662    /// Record a matching path with an already-known [`StringId`].
663    fn record_match(&mut self, path_id: StringId, path: &str, match_type: MatchType) -> bool {
664        let Some(score) = self.score_path(path) else {
665            return false;
666        };
667        push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
668        true
669    }
670
671    fn record_scored_match(&mut self, path_id: StringId, score: u32, match_type: MatchType) {
672        push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
673    }
674
675    fn record_owned_match(&mut self, path: &str, score: u32, match_type: MatchType) {
676        if self.owned_matches.len() == self.limit {
677            let Some(Reverse(worst)) = self.owned_matches.peek() else {
678                return;
679            };
680            if !owned_match_is_better(score, path, match_type, worst) {
681                return;
682            }
683        }
684
685        push_owned_match(
686            &mut self.owned_matches,
687            self.limit,
688            OwnedMatchCandidate { score, path: path.to_owned(), match_type },
689        );
690    }
691}
692
693fn push_owned_match(
694    matches: &mut BinaryHeap<Reverse<OwnedMatchCandidate>>,
695    limit: usize,
696    candidate: OwnedMatchCandidate,
697) {
698    if matches.len() == limit {
699        let Some(Reverse(worst)) = matches.peek() else {
700            return;
701        };
702        if !owned_match_is_better(candidate.score, candidate.path.as_str(), candidate.match_type, worst) {
703            return;
704        }
705        matches.pop();
706    }
707
708    matches.push(Reverse(candidate));
709}
710
711fn owned_match_is_better(score: u32, path: &str, match_type: MatchType, worst: &OwnedMatchCandidate) -> bool {
712    score
713        .cmp(&worst.score)
714        .then_with(|| worst.path.as_str().cmp(path))
715        .then_with(|| worst.match_type.cmp(&match_type))
716        .is_gt()
717}
718
719fn push_top_match(
720    matches: &mut BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
721    limit: usize,
722    score: u32,
723    path: StringId,
724    match_type: MatchType,
725) -> bool {
726    let candidate = (score, path, match_type);
727    if matches.len() < limit {
728        matches.push(Reverse(candidate));
729        return true;
730    }
731
732    let Some(minimum) = matches.peek().map(|entry| &entry.0) else {
733        return false;
734    };
735
736    if &candidate <= minimum {
737        return false;
738    }
739
740    matches.pop();
741    matches.push(Reverse(candidate));
742    true
743}
744
745/// Run fuzzy file search using a pre-computed file index.
746///
747/// This is much faster than `run()` for repeated queries on the same
748/// directory because it avoids re-traversing the filesystem.
749///
750/// # Arguments
751///
752/// * `config` - File search configuration
753/// * `index_cache` - Shared cache for the pre-computed file index
754///
755/// # Returns
756///
757/// FileSearchResults containing matched files and total match count.
758pub async fn run_with_index(
759    config: FileSearchConfig,
760    index_cache: &FileIndexCache,
761) -> anyhow::Result<FileSearchResults> {
762    let limit = config.limit.get();
763    let cancel_flag = &config.cancel_flag;
764    let compute_indices = config.compute_indices;
765
766    // Get or build the file index
767    let index = index_cache.get_or_build().await?;
768
769    // Check cancellation
770    if cancel_flag.load(Ordering::Relaxed) {
771        return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
772    }
773
774    // Query the index off the async runtime thread to avoid stalling
775    // the tokio worker while rayon parallel-scoring runs.
776    let index_for_results = index.clone();
777    let matched_paths = tokio::task::spawn_blocking({
778        let pattern_text = config.pattern_text.clone();
779        move || Ok::<_, anyhow::Error>(index.query(&pattern_text, limit, None))
780    })
781    .await??;
782
783    let total_match_count = matched_paths.len();
784
785    // Build final results
786    let matches = matched_paths
787        .into_iter()
788        .filter_map(|(score, path_id, match_type)| {
789            let path = index_for_results.path_texts_by_id.get(path_id.as_u32() as usize)?.to_string();
790            Some(FileMatch {
791                score,
792                path,
793                match_type,
794                indices: if compute_indices { Some(Vec::new()) } else { None },
795            })
796        })
797        .collect();
798
799    Ok(FileSearchResults { matches, total_match_count })
800}
801
802/// Run fuzzy file search with parallel traversal.
803///
804/// # Arguments
805///
806/// * `config` - File search configuration containing all search parameters
807///
808/// # Returns
809///
810/// FileSearchResults containing matched files and total match count.
811pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
812    run_with_policy(config, true, false)
813}
814
815/// Run a bounded fuzzy path search without following symbolic links.
816///
817/// This focused route is intended for request-scoped code search. It traverses
818/// eligible paths in deterministic order and stops at the candidate cap. It
819/// deliberately avoids the persistent [`FileIndexCache`].
820pub fn run_bounded_no_follow(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
821    run_bounded_no_follow_with_visit(config, |_| {})
822}
823
824fn run_bounded_no_follow_with_visit(
825    config: FileSearchConfig,
826    mut visit: impl FnMut(&Path),
827) -> anyhow::Result<FileSearchResults> {
828    let limit = config.limit.get();
829    let search_directory = &config.search_directory;
830    let mut walk_builder = ignore::WalkBuilder::new(search_directory);
831    vtcode_commons::walk::apply_defaults(&mut walk_builder);
832    walk_builder
833        .follow_links(false)
834        .require_git(false)
835        .sort_by_file_path(|left, right| left.cmp(right));
836
837    if !config.respect_gitignore {
838        walk_builder
839            .git_ignore(false)
840            .git_global(false)
841            .git_exclude(false)
842            .ignore(false)
843            .parents(false);
844    }
845
846    if !config.exclude.is_empty() {
847        let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
848        for exclude_pattern in &config.exclude {
849            override_builder.add(&format!("!{exclude_pattern}"))?;
850        }
851        walk_builder.overrides(override_builder.build()?);
852    }
853
854    let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
855    let mut matches = BestMatchesList::new(limit, &config.pattern_text);
856    let mut matching_count = 0usize;
857    for result in walk_builder.build() {
858        if config.cancel_flag.load(Ordering::Relaxed) {
859            break;
860        }
861        let entry = match result {
862            Ok(entry) => entry,
863            Err(_) => continue,
864        };
865        visit(entry.path());
866        if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
867            continue;
868        }
869        let Some(relative_path) = entry
870            .path()
871            .strip_prefix(search_directory)
872            .ok()
873            .and_then(|path| path.to_str())
874            .filter(|path| !path.is_empty())
875        else {
876            continue;
877        };
878        let Some(score) = matches.score_path(relative_path) else {
879            continue;
880        };
881        let path_id = interner.lock().intern(relative_path);
882        matches.record_scored_match(path_id, score, MatchType::File);
883        matching_count += 1;
884        if matching_count >= limit {
885            break;
886        }
887    }
888
889    let interner_guard = interner.lock();
890    let matches = matches
891        .matches
892        .into_sorted_vec()
893        .into_iter()
894        .filter_map(|Reverse((score, path_id, match_type))| {
895            let path = interner_guard.get(path_id)?.to_string();
896            Some(FileMatch {
897                score,
898                path,
899                match_type,
900                indices: config.compute_indices.then(Vec::new),
901            })
902        })
903        .collect();
904
905    Ok(FileSearchResults {
906        matches,
907        // Reaching the cap terminates traversal, so report conservative
908        // truncation without scanning the rest of the tree for an exact total.
909        total_match_count: matching_count + usize::from(matching_count >= limit),
910    })
911}
912
913fn run_with_policy(
914    config: FileSearchConfig,
915    follow_links: bool,
916    files_only: bool,
917) -> anyhow::Result<FileSearchResults> {
918    let limit = config.limit.get();
919    let search_directory = &config.search_directory;
920    let exclude = &config.exclude;
921    let threads = config.threads.get();
922    let cancel_flag = &config.cancel_flag;
923    let compute_indices = config.compute_indices;
924    let respect_gitignore = config.respect_gitignore;
925
926    let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, follow_links)?;
927
928    let worker_results = Arc::new(Mutex::new(SearchAggregation { matches: BinaryHeap::new(), total_match_count: 0 }));
929    walker.run(|| {
930        let mut state = SearchWorkerGuard {
931            results: Arc::clone(&worker_results),
932            best_list: BestMatchesList::new(limit, &config.pattern_text),
933            total_match_count: 0,
934            limit,
935        };
936
937        Box::new(move |result| {
938            // Check cancellation flag periodically
939            if cancel_flag.load(Ordering::Relaxed) {
940                return ignore::WalkState::Quit;
941            }
942
943            let entry = match result {
944                Ok(e) => e,
945                Err(_) => return ignore::WalkState::Continue,
946            };
947
948            // Make path relative to search directory
949            let relative_path = entry.path().strip_prefix(search_directory).ok().and_then(|p| p.to_str());
950
951            let path_to_match = match relative_path {
952                Some(p) if !p.is_empty() => p,
953                _ => return ignore::WalkState::Continue, // Skip root and non-relative paths
954            };
955
956            let Some(file_type) = entry.file_type() else {
957                return ignore::WalkState::Continue;
958            };
959            let match_type = if file_type.is_dir() {
960                MatchType::Directory
961            } else {
962                MatchType::File
963            };
964
965            if files_only && match_type == MatchType::Directory {
966                return ignore::WalkState::Continue;
967            }
968
969            // Try to add to results - no contention with other workers
970            let Some(score) = state.best_list.score_path(path_to_match) else {
971                return ignore::WalkState::Continue;
972            };
973            state.best_list.record_owned_match(path_to_match, score, match_type);
974            state.total_match_count += 1;
975
976            ignore::WalkState::Continue
977        })
978    });
979
980    let results = Arc::try_unwrap(worker_results)
981        .map_err(|results| {
982            anyhow::anyhow!("file search worker results still referenced: {}", Arc::strong_count(&results))
983        })?
984        .into_inner();
985    let total_match_count = results.total_match_count;
986    let mut candidates = results
987        .matches
988        .into_iter()
989        .map(|Reverse(candidate)| candidate)
990        .collect::<Vec<_>>();
991    candidates.sort_by(|left, right| {
992        right
993            .score
994            .cmp(&left.score)
995            .then_with(|| left.path.cmp(&right.path))
996            .then_with(|| left.match_type.cmp(&right.match_type))
997    });
998    candidates.truncate(limit);
999
1000    // Build final results
1001    let matches = candidates
1002        .into_iter()
1003        .map(|candidate| FileMatch {
1004            score: candidate.score,
1005            path: candidate.path,
1006            match_type: candidate.match_type,
1007            indices: if compute_indices { Some(Vec::new()) } else { None },
1008        })
1009        .collect();
1010
1011    Ok(FileSearchResults { matches, total_match_count })
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::{
1017        FileIndexCache, FileSearchConfig, MatchType, run, run_bounded_no_follow, run_bounded_no_follow_with_visit,
1018        run_with_index,
1019    };
1020    use std::num::NonZero;
1021    use std::sync::Arc;
1022    use std::sync::atomic::AtomicBool;
1023    use tempfile::TempDir;
1024
1025    #[tokio::test(flavor = "current_thread")]
1026    async fn concurrent_index_builds_share_async_cache_entry() {
1027        let workspace = TempDir::new().expect("workspace");
1028        std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1029
1030        let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), true, 1);
1031        let (first, second) = tokio::join!(cache.get_or_build(), cache.get_or_build());
1032        let first = first.expect("build file index");
1033        let second = second.expect("reuse file index");
1034
1035        assert!(Arc::ptr_eq(&first, &second));
1036    }
1037
1038    #[tokio::test(flavor = "current_thread")]
1039    async fn background_refresh_is_safe_when_called_from_tokio() {
1040        let workspace = TempDir::new().expect("workspace");
1041        std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1042
1043        let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1044        cache.get_or_build().await.expect("initial index");
1045
1046        assert!(cache.refresh_background().is_some());
1047    }
1048
1049    #[test]
1050    fn background_refresh_returns_snapshot_without_runtime() {
1051        let workspace = TempDir::new().expect("workspace");
1052        std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1053
1054        let runtime = tokio::runtime::Runtime::new().expect("Tokio runtime");
1055        let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1056        runtime.block_on(cache.get_or_build()).expect("initial index");
1057        drop(runtime);
1058
1059        assert!(cache.refresh_background().is_some());
1060    }
1061
1062    #[tokio::test(flavor = "current_thread")]
1063    async fn incremental_directory_updates_use_the_cache_root() {
1064        let workspace = TempDir::new().expect("workspace");
1065        std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1066        std::fs::create_dir(workspace.path().join("new_directory")).expect("fixture directory");
1067
1068        let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
1069        cache.get_or_build().await.expect("initial index");
1070
1071        tokio::task::spawn_blocking({
1072            let cache = Arc::clone(&cache);
1073            move || cache.update_file("new_directory", true)
1074        })
1075        .await
1076        .expect("incremental directory update task");
1077
1078        let index = cache.get_or_build().await.expect("updated index");
1079        let matches = index.query("new_directory", 16, None);
1080        assert!(matches.iter().any(|(_, _, match_type)| *match_type == MatchType::Directory));
1081    }
1082
1083    fn indexed_search_config(
1084        workspace: &std::path::Path,
1085        pattern: &str,
1086        cancel_flag: Arc<AtomicBool>,
1087    ) -> FileSearchConfig {
1088        FileSearchConfig {
1089            pattern_text: pattern.to_string(),
1090            limit: NonZero::new(16).expect("non-zero limit"),
1091            search_directory: workspace.to_path_buf(),
1092            exclude: Vec::new(),
1093            threads: NonZero::new(1).expect("non-zero threads"),
1094            cancel_flag,
1095            compute_indices: false,
1096            respect_gitignore: false,
1097        }
1098    }
1099
1100    fn result_signature(results: &super::FileSearchResults) -> Vec<(u32, String, MatchType)> {
1101        results
1102            .matches
1103            .iter()
1104            .map(|candidate| (candidate.score, candidate.path.clone(), candidate.match_type))
1105            .collect()
1106    }
1107
1108    #[tokio::test(flavor = "current_thread")]
1109    async fn indexed_search_preserves_scores_order_and_match_types() {
1110        let workspace = TempDir::new().expect("workspace");
1111        std::fs::create_dir_all(workspace.path().join("src/widget_dir")).expect("fixture directory");
1112        std::fs::write(workspace.path().join("src/widget.rs"), "fn widget() {}\n").expect("fixture source");
1113        std::fs::write(workspace.path().join("src/widget_test.rs"), "fn widget_test() {}\n").expect("fixture source");
1114        std::fs::write(workspace.path().join("README.md"), "widget documentation\n").expect("fixture docs");
1115
1116        let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1117        let first =
1118            run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1119                .await
1120                .expect("indexed search");
1121        let second =
1122            run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1123                .await
1124                .expect("repeat indexed search");
1125
1126        assert_eq!(result_signature(&first), result_signature(&second));
1127        assert!(
1128            first
1129                .matches
1130                .iter()
1131                .any(|candidate| candidate.match_type == MatchType::Directory)
1132        );
1133        assert!(first.matches.iter().any(|candidate| candidate.match_type == MatchType::File));
1134        assert!(first.matches.windows(2).all(|window| window[0].score >= window[1].score));
1135    }
1136
1137    #[tokio::test(flavor = "current_thread")]
1138    async fn indexed_search_honors_cancellation_before_scoring() {
1139        let workspace = TempDir::new().expect("workspace");
1140        std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1141        let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1142        let cancel_flag = Arc::new(AtomicBool::new(true));
1143
1144        let results = run_with_index(indexed_search_config(workspace.path(), "widget", cancel_flag), &cache)
1145            .await
1146            .expect("cancelled indexed search");
1147        assert!(results.matches.is_empty());
1148        assert_eq!(results.total_match_count, 0);
1149    }
1150
1151    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1152    async fn incremental_updates_do_not_mutate_an_old_search_index() {
1153        let workspace = TempDir::new().expect("workspace");
1154        std::fs::write(workspace.path().join("old_widget.rs"), "fn old_widget() {}\n").expect("fixture source");
1155        let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
1156        let old_index = cache.get_or_build().await.expect("initial index");
1157
1158        tokio::task::spawn_blocking({
1159            let cache = Arc::clone(&cache);
1160            move || cache.update_file("new_widget.rs", true)
1161        })
1162        .await
1163        .expect("incremental update task");
1164
1165        let old_matches = old_index.query("widget", 16, None);
1166        let new_index = cache.get_or_build().await.expect("updated index");
1167        let new_matches = new_index.query("widget", 16, None);
1168        let old_paths = old_matches
1169            .iter()
1170            .filter_map(|(_, path_id, _)| old_index.path_texts_by_id.get(path_id.as_u32() as usize))
1171            .map(AsRef::as_ref)
1172            .collect::<Vec<&str>>();
1173        let new_paths = new_matches
1174            .iter()
1175            .filter_map(|(_, path_id, _)| new_index.path_texts_by_id.get(path_id.as_u32() as usize))
1176            .map(AsRef::as_ref)
1177            .collect::<Vec<&str>>();
1178
1179        assert!(!old_paths.contains(&"new_widget.rs"));
1180        assert!(new_paths.contains(&"new_widget.rs"));
1181    }
1182
1183    fn uncached_search_config(
1184        workspace: &std::path::Path,
1185        pattern: &str,
1186        limit: usize,
1187        cancel_flag: Arc<AtomicBool>,
1188    ) -> FileSearchConfig {
1189        FileSearchConfig {
1190            pattern_text: pattern.to_string(),
1191            limit: NonZero::new(limit).expect("non-zero limit"),
1192            search_directory: workspace.to_path_buf(),
1193            exclude: Vec::new(),
1194            threads: NonZero::new(4).expect("non-zero threads"),
1195            cancel_flag,
1196            compute_indices: false,
1197            respect_gitignore: false,
1198        }
1199    }
1200
1201    #[test]
1202    fn uncached_parallel_search_aggregates_worker_paths_and_counts() {
1203        let workspace = TempDir::new().expect("workspace");
1204        for index in 0..64 {
1205            std::fs::create_dir(workspace.path().join(format!("widget_dir_{index:03}"))).expect("fixture directory");
1206            std::fs::write(workspace.path().join(format!("widget_file_{index:03}.rs")), "fn widget() {}\n")
1207                .expect("fixture source");
1208        }
1209
1210        let results = run(uncached_search_config(workspace.path(), "widget", 32, Arc::new(AtomicBool::new(false))))
1211            .expect("uncached parallel search");
1212
1213        assert_eq!(results.total_match_count, 128);
1214        assert_eq!(results.matches.len(), 32);
1215        let unique_paths = results
1216            .matches
1217            .iter()
1218            .map(|candidate| candidate.path.as_str())
1219            .collect::<std::collections::HashSet<_>>();
1220        assert_eq!(unique_paths.len(), results.matches.len());
1221        assert!(results.matches.iter().all(|candidate| candidate.path.contains("widget")));
1222    }
1223
1224    #[test]
1225    fn uncached_parallel_search_honors_cancellation() {
1226        let workspace = TempDir::new().expect("workspace");
1227        for index in 0..32 {
1228            std::fs::write(workspace.path().join(format!("widget_{index:03}.rs")), "fn widget() {}\n")
1229                .expect("fixture source");
1230        }
1231        let cancel_flag = Arc::new(AtomicBool::new(true));
1232
1233        let results = run(uncached_search_config(workspace.path(), "widget", 16, cancel_flag))
1234            .expect("cancelled uncached search");
1235
1236        assert!(results.matches.is_empty());
1237        assert_eq!(results.total_match_count, 0);
1238    }
1239
1240    #[test]
1241    fn uncached_equal_score_results_are_lexically_ordered_and_repeatable() {
1242        let workspace = TempDir::new().expect("workspace");
1243        for name in ["a_x.rs", "b_x.rs", "c_x.rs", "d_x.rs", "e_x.rs"] {
1244            std::fs::write(workspace.path().join(name), "fn x() {}\n").expect("fixture source");
1245        }
1246
1247        let search = || {
1248            run(uncached_search_config(workspace.path(), "x", 3, Arc::new(AtomicBool::new(false))))
1249                .expect("uncached equal-score search")
1250                .matches
1251                .into_iter()
1252                .map(|candidate| (candidate.score, candidate.path))
1253                .collect::<Vec<_>>()
1254        };
1255
1256        let expected = search();
1257        for _ in 0..20 {
1258            assert_eq!(search(), expected);
1259        }
1260        assert!(
1261            expected.windows(2).all(|window| {
1262                window[0].0 > window[1].0 || (window[0].0 == window[1].0 && window[0].1 <= window[1].1)
1263            })
1264        );
1265    }
1266
1267    fn bounded_paths(workspace: &std::path::Path) -> Vec<String> {
1268        run_bounded_no_follow(FileSearchConfig {
1269            pattern_text: "widget".to_string(),
1270            limit: NonZero::new(2).expect("non-zero limit"),
1271            search_directory: workspace.to_path_buf(),
1272            exclude: Vec::new(),
1273            threads: NonZero::new(4).expect("non-zero threads"),
1274            cancel_flag: Arc::new(AtomicBool::new(false)),
1275            compute_indices: false,
1276            respect_gitignore: true,
1277        })
1278        .expect("bounded path search")
1279        .matches
1280        .into_iter()
1281        .map(|candidate| candidate.path)
1282        .collect()
1283    }
1284
1285    #[test]
1286    fn bounded_path_selection_is_stable_across_repeated_walks() {
1287        let workspace = TempDir::new().expect("workspace");
1288        for directory in ["z", "a", "m", "b", "y"] {
1289            let directory = workspace.path().join(directory);
1290            std::fs::create_dir(&directory).expect("fixture directory");
1291            std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1292        }
1293
1294        let expected = bounded_paths(workspace.path());
1295        assert_eq!(expected.len(), 2);
1296        for _ in 0..20 {
1297            assert_eq!(bounded_paths(workspace.path()), expected);
1298        }
1299    }
1300
1301    #[test]
1302    fn bounded_path_selection_is_the_sorted_prefix_and_stops_early() {
1303        let workspace = TempDir::new().expect("workspace");
1304        for directory in ["z", "a", "m", "b", "y"] {
1305            let directory = workspace.path().join(directory);
1306            std::fs::create_dir(&directory).expect("fixture directory");
1307            std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1308        }
1309        let mut visited = Vec::new();
1310
1311        let results = run_bounded_no_follow_with_visit(
1312            FileSearchConfig {
1313                pattern_text: "widget".to_string(),
1314                limit: NonZero::new(2).expect("non-zero limit"),
1315                search_directory: workspace.path().to_path_buf(),
1316                exclude: Vec::new(),
1317                threads: NonZero::new(4).expect("non-zero threads"),
1318                cancel_flag: Arc::new(AtomicBool::new(false)),
1319                compute_indices: false,
1320                respect_gitignore: true,
1321            },
1322            |path| visited.push(path.to_path_buf()),
1323        )
1324        .expect("bounded path search");
1325        let mut paths = results.matches.into_iter().map(|candidate| candidate.path).collect::<Vec<_>>();
1326        paths.sort();
1327
1328        assert_eq!(paths, vec!["a/widget.rs", "b/widget.rs"]);
1329        assert!(visited.len() < 11, "the bounded route must stop before traversing the complete fixture tree");
1330        assert_eq!(results.total_match_count, 3);
1331    }
1332}