Skip to main content

vtcode_indexer/file_search/
mod.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
51mod types;
52
53pub use types::{FileMatch, FileSearchConfig, FileSearchResults, MatchType};
54
55/// Pre-computed file index for instant queries.
56///
57/// This index is built in the background and cached to avoid
58/// repeated directory traversals on every search.
59pub struct FileIndex {
60    files: Vec<StringId>,
61    directories: Vec<StringId>,
62    /// Immutable path text indexed by the corresponding [`StringId`].
63    /// Searches only read this table, so scoring never contends on the
64    /// incremental-update interner or allocates a candidate string.
65    path_texts_by_id: Arc<Vec<Arc<str>>>,
66    /// Used only while applying incremental index updates.
67    interner: vtcode_commons::StringInterner,
68    last_built: std::time::Instant,
69}
70
71/// Build a parallel walker with the given configuration.
72fn build_parallel_walker(
73    search_directory: &Path,
74    exclude: &[String],
75    threads: usize,
76    respect_gitignore: bool,
77    follow_links: bool,
78) -> anyhow::Result<ignore::WalkParallel> {
79    let mut walk_builder = ignore::WalkBuilder::new(search_directory);
80    vtcode_commons::walk::apply_defaults(&mut walk_builder);
81
82    // File-search-specific overrides
83    walk_builder.threads(threads);
84    walk_builder.follow_links(follow_links);
85    walk_builder.require_git(false); // Search works outside git repos
86
87    if !respect_gitignore {
88        walk_builder
89            .git_ignore(false)
90            .git_global(false)
91            .git_exclude(false)
92            .ignore(false)
93            .parents(false);
94    }
95
96    if !exclude.is_empty() {
97        let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
98        for exclude_pattern in exclude {
99            let pattern = format!("!{exclude_pattern}");
100            override_builder.add(&pattern)?;
101        }
102        walk_builder.overrides(override_builder.build()?);
103    }
104
105    Ok(walk_builder.build_parallel())
106}
107
108impl FileIndex {
109    /// Build a file index by traversing the directory tree.
110    /// This is expensive but only done once.
111    fn build_from_directory(
112        search_directory: &Path,
113        exclude: &[String],
114        respect_gitignore: bool,
115        threads: usize,
116    ) -> anyhow::Result<Self> {
117        let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, true)?;
118
119        // Collect all files and directories
120        let worker_results = Arc::new(Mutex::new(Vec::new()));
121
122        walker.run(|| {
123            let search_dir = search_directory.to_path_buf();
124            let mut state = IndexWorkerGuard {
125                results: Arc::clone(&worker_results),
126                files: Vec::new(),
127                directories: Vec::new(),
128            };
129
130            Box::new(move |result| {
131                let entry = match result {
132                    Ok(e) => e,
133                    Err(_) => return ignore::WalkState::Continue,
134                };
135
136                // Make path relative to search directory
137                if let Some(rel_path) = entry.path().strip_prefix(&search_dir).ok().and_then(|p| p.to_str())
138                    && !rel_path.is_empty()
139                {
140                    if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
141                        state.directories.push(rel_path.to_string());
142                    } else {
143                        state.files.push(rel_path.to_string());
144                    }
145                }
146
147                ignore::WalkState::Continue
148            })
149        });
150
151        let worker_results = Arc::try_unwrap(worker_results)
152            .map_err(|results| {
153                anyhow::anyhow!("file index worker results still referenced: {}", Arc::strong_count(&results))
154            })?
155            .into_inner();
156        let (files, directories) =
157            worker_results
158                .into_iter()
159                .fold((Vec::new(), Vec::new()), |(mut files, mut directories), result| {
160                    files.extend(result.files);
161                    directories.extend(result.directories);
162                    (files, directories)
163                });
164
165        // Pre-size the interner arena and lookup tables from the collected
166        // paths so the build below does not repeatedly realloc and rehash.
167        let total_path_bytes: usize =
168            files.iter().map(String::len).sum::<usize>() + directories.iter().map(String::len).sum::<usize>();
169        let mut interner =
170            vtcode_commons::StringInterner::with_capacity(total_path_bytes, files.len() + directories.len());
171        let mut path_texts_by_id = Vec::with_capacity(files.len() + directories.len());
172        let interned_files: Vec<StringId> = files
173            .iter()
174            .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
175            .collect();
176        let interned_dirs: Vec<StringId> = directories
177            .iter()
178            .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
179            .collect();
180
181        Ok(Self {
182            files: interned_files,
183            directories: interned_dirs,
184            path_texts_by_id: Arc::new(path_texts_by_id),
185            interner,
186            last_built: std::time::Instant::now(),
187        })
188    }
189
190    /// Query the index for matching paths.
191    /// Much faster than re-traversing the filesystem.
192    fn query(
193        &self,
194        pattern_text: &str,
195        limit: usize,
196        match_type_filter: Option<MatchType>,
197    ) -> Vec<(u32, StringId, MatchType)> {
198        // `query` stays serial and declarative: the parallel scoring strategy
199        // is isolated behind `score_paths_top_k`, and the per-chunk top-K heaps
200        // are merged by the shared `merge_top_k` helper. This keeps the index
201        // query logic testable without a rayon runtime in the loop.
202        let mut heaps = Vec::new();
203
204        if match_type_filter.is_none_or(|t| t == MatchType::File) {
205            heaps.push(score_paths_top_k(
206                &self.files,
207                self.path_texts_by_id.as_slice(),
208                limit,
209                pattern_text,
210                MatchType::File,
211            ));
212        }
213
214        if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
215            heaps.push(score_paths_top_k(
216                &self.directories,
217                self.path_texts_by_id.as_slice(),
218                limit,
219                pattern_text,
220                MatchType::Directory,
221            ));
222        }
223
224        merge_top_k(heaps, limit)
225            .into_sorted_vec()
226            .into_iter()
227            .map(|Reverse(item)| item)
228            .collect()
229    }
230}
231
232fn intern_path(
233    path: &str,
234    interner: &mut vtcode_commons::StringInterner,
235    path_texts_by_id: &mut Vec<Arc<str>>,
236) -> StringId {
237    let path_id = interner.intern(path);
238    let path_index = path_id.as_u32() as usize;
239    if path_index == path_texts_by_id.len() {
240        path_texts_by_id.push(Arc::from(path));
241    }
242    path_id
243}
244
245/// Score `paths` in parallel rayon chunks, returning the worker-merged top-K
246/// heap for `match_type`.
247///
248/// This is the single boundary for the parallel scoring strategy: each worker
249/// thread gets its own `BestMatchesList` (matcher + haystack buffer reused via
250/// `map_init`), keeps its own top-K heap, and the partial heaps are merged by
251/// `merge_top_k`. Callers must not depend on equal-score ordering.
252fn score_paths_top_k(
253    paths: &[StringId],
254    path_texts_by_id: &[Arc<str>],
255    limit: usize,
256    pattern_text: &str,
257    match_type: MatchType,
258) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
259    const CHUNK: usize = 1024;
260
261    // Serial fast path for small inputs: avoids the rayon thread-pool spawn
262    // overhead and keeps equal-score ordering deterministic.
263    if paths.len() <= CHUNK {
264        let mut list = BestMatchesList::new(limit, pattern_text);
265        for &path_id in paths {
266            if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
267                list.record_match(path_id, path, match_type);
268            }
269        }
270        return list.matches;
271    }
272
273    let heaps: Vec<_> = paths
274        .par_chunks(CHUNK)
275        .map_init(
276            || BestMatchesList::new(limit, pattern_text),
277            |list, chunk| {
278                for &path_id in chunk {
279                    if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
280                        list.record_match(path_id, path, match_type);
281                    }
282                }
283                std::mem::take(&mut list.matches)
284            },
285        )
286        .collect();
287
288    merge_top_k(heaps, limit)
289}
290
291/// Merge worker-local top-K heaps into a single top-K heap.
292///
293/// Because each input heap already holds only its own highest-scoring `limit`
294/// entries, the global top-K is a subset of their union; merging and re-keeping
295/// the top-K yields the correct global result.
296fn merge_top_k(
297    heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>>,
298    limit: usize,
299) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
300    let mut merged = BinaryHeap::with_capacity(limit);
301    for heap in heaps {
302        for Reverse(item) in heap.into_vec() {
303            push_top_match(&mut merged, limit, item.0, item.1, item.2);
304        }
305    }
306    merged
307}
308
309/// A cached file index that can be shared across searches.
310pub struct FileIndexCache {
311    cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
312    /// Lock-free copy of the latest published index for synchronous callers.
313    /// This keeps `refresh_background` from blocking or panicking when called
314    /// on a Tokio worker that is concurrently publishing a replacement.
315    snapshot: Arc<ArcSwapOption<FileIndex>>,
316    /// Serializes full index builds so concurrent cache misses do not launch
317    /// duplicate workspace traversals and Rayon jobs.
318    build_gate: Arc<tokio::sync::Semaphore>,
319    search_directory: std::path::PathBuf,
320    exclude: Vec<String>,
321    respect_gitignore: bool,
322    threads: usize,
323}
324
325impl FileIndexCache {
326    pub fn new(
327        search_directory: std::path::PathBuf,
328        exclude: impl IntoIterator<Item = String>,
329        respect_gitignore: bool,
330        threads: usize,
331    ) -> Self {
332        Self {
333            cache: Arc::new(RwLock::new(None)),
334            snapshot: Arc::new(ArcSwapOption::empty()),
335            build_gate: Arc::new(tokio::sync::Semaphore::new(1)),
336            search_directory,
337            exclude: exclude.into_iter().collect(),
338            respect_gitignore,
339            threads,
340        }
341    }
342
343    /// Get or build the file index.
344    pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
345        // Check if we have a cached index
346        {
347            let guard = self.cache.read().await;
348            if let Some(index) = guard.as_ref() {
349                // Check if index is stale (older than 5 minutes)
350                if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
351                    return Ok(Arc::clone(index));
352                }
353            }
354        }
355
356        // Re-check after waiting for another caller's build. This avoids a
357        // cache-stampede when several searches arrive on an empty/stale cache.
358        let _build_permit = self.build_gate.acquire().await?;
359        {
360            let guard = self.cache.read().await;
361            if let Some(index) = guard.as_ref()
362                && index.last_built.elapsed() < std::time::Duration::from_secs(300)
363            {
364                return Ok(Arc::clone(index));
365            }
366        }
367
368        // Directory traversal and index construction are synchronous and can
369        // touch a large workspace. Keep that work off the Tokio worker so a
370        // cache miss cannot delay unrelated async tasks.
371        let search_directory = self.search_directory.clone();
372        let exclude = self.exclude.clone();
373        let respect_gitignore = self.respect_gitignore;
374        let threads = self.threads;
375        let index = Arc::new(
376            tokio::task::spawn_blocking(move || {
377                FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
378            })
379            .await??,
380        );
381
382        // Cache and return
383        {
384            let mut guard = self.cache.write().await;
385            *guard = Some(Arc::clone(&index));
386            self.snapshot.store(Some(Arc::clone(&index)));
387        }
388        Ok(index)
389    }
390
391    /// Force refresh the index in the background.
392    ///
393    /// Returns the latest published index immediately while rebuilding happens
394    /// asynchronously. If no Tokio runtime is available, no refresh is
395    /// scheduled and the latest published index is returned unchanged.
396    pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
397        let runtime = match tokio::runtime::Handle::try_current() {
398            Ok(runtime) => runtime,
399            Err(error) => {
400                tracing::debug!(%error, "cannot refresh file index without a Tokio runtime");
401                return self.snapshot.load_full();
402            }
403        };
404
405        // Build new index asynchronously
406        let search_directory = self.search_directory.clone();
407        let exclude = self.exclude.clone();
408        let respect_gitignore = self.respect_gitignore;
409        let threads = self.threads;
410        let cache = self.cache.clone();
411        let snapshot = Arc::clone(&self.snapshot);
412        let build_gate = Arc::clone(&self.build_gate);
413
414        runtime.spawn(async move {
415            let _build_permit = match build_gate.acquire_owned().await {
416                Ok(permit) => permit,
417                Err(error) => {
418                    tracing::error!(%error, "file index build gate closed");
419                    return;
420                }
421            };
422
423            match tokio::task::spawn_blocking(move || {
424                FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
425            })
426            .await
427            {
428                Ok(Ok(new_index)) => {
429                    let new_index = Arc::new(new_index);
430                    let mut guard = cache.write().await;
431                    *guard = Some(Arc::clone(&new_index));
432                    snapshot.store(Some(new_index));
433                }
434                Ok(Err(error)) => {
435                    tracing::error!(%error, "failed to rebuild file index");
436                }
437                Err(error) => {
438                    tracing::error!(%error, "file index rebuild task failed");
439                }
440            }
441        });
442
443        self.snapshot.load_full()
444    }
445
446    /// Incrementally update the index when a file change is detected.
447    /// This is faster than a full rebuild for single file changes.
448    pub fn update_file(&self, path: &str, is_added: bool) {
449        let mut guard = self.cache.blocking_write();
450        let Some(existing) = guard.take() else { return };
451
452        let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
453        if is_added {
454            let path_id = intern_path(path, &mut index.interner, Arc::make_mut(&mut index.path_texts_by_id));
455            let is_directory = self.search_directory.join(path).is_dir();
456            if is_directory {
457                index.files.retain(|&existing| existing != path_id);
458                if !index.directories.contains(&path_id) {
459                    index.directories.push(path_id);
460                }
461            } else {
462                index.directories.retain(|&existing| existing != path_id);
463                if !index.files.contains(&path_id) {
464                    index.files.push(path_id);
465                }
466            }
467        } else {
468            let Some(path_id) = index.files.iter().chain(index.directories.iter()).copied().find(|&path_id| {
469                index
470                    .path_texts_by_id
471                    .get(path_id.as_u32() as usize)
472                    .is_some_and(|value| value.as_ref() == path)
473            }) else {
474                let index = Arc::new(index);
475                *guard = Some(Arc::clone(&index));
476                self.snapshot.store(Some(index));
477                return;
478            };
479            index.files.retain(|&existing| existing != path_id);
480            index.directories.retain(|&existing| existing != path_id);
481        }
482        index.last_built = std::time::Instant::now();
483        let index = Arc::new(index);
484        *guard = Some(Arc::clone(&index));
485        self.snapshot.store(Some(index));
486    }
487
488    /// Get the age of the current index.
489    pub async fn index_age(&self) -> Option<std::time::Duration> {
490        let guard = self.cache.read().await;
491        guard.as_ref().map(|idx| idx.last_built.elapsed())
492    }
493}
494
495// Make FileIndex cloneable
496impl Clone for FileIndex {
497    fn clone(&self) -> Self {
498        Self {
499            files: self.files.clone(),
500            directories: self.directories.clone(),
501            path_texts_by_id: Arc::clone(&self.path_texts_by_id),
502            interner: self.interner.clone(),
503            last_built: self.last_built,
504        }
505    }
506}
507
508pub use vtcode_commons::paths::file_name_from_path;
509
510/// Best matches list per worker thread (lock-free collection).
511///
512/// Each worker thread gets its own instance to avoid locking during
513/// directory traversal. Results are merged at the end.
514struct BestMatchesList {
515    matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
516    limit: usize,
517    matcher: nucleo_matcher::Matcher,
518    haystack_buf: Vec<char>,
519    /// Pre-computed pattern - avoids per-match UTF-32 conversion
520    pattern: PatternStorage,
521    owned_matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
522}
523
524#[derive(Debug, Eq, PartialEq)]
525struct OwnedMatchCandidate {
526    score: u32,
527    path: String,
528    match_type: MatchType,
529}
530
531impl Ord for OwnedMatchCandidate {
532    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
533        self.score
534            .cmp(&other.score)
535            .then_with(|| other.path.cmp(&self.path))
536            .then_with(|| other.match_type.cmp(&self.match_type))
537    }
538}
539
540impl PartialOrd for OwnedMatchCandidate {
541    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
542        Some(self.cmp(other))
543    }
544}
545
546struct SearchAggregation {
547    matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
548    total_match_count: usize,
549}
550
551struct IndexWorkerResult {
552    files: Vec<String>,
553    directories: Vec<String>,
554}
555
556struct IndexWorkerGuard {
557    results: Arc<Mutex<Vec<IndexWorkerResult>>>,
558    files: Vec<String>,
559    directories: Vec<String>,
560}
561
562impl Drop for IndexWorkerGuard {
563    fn drop(&mut self) {
564        self.results.lock().push(IndexWorkerResult {
565            files: std::mem::take(&mut self.files),
566            directories: std::mem::take(&mut self.directories),
567        });
568    }
569}
570
571struct SearchWorkerGuard {
572    results: Arc<Mutex<SearchAggregation>>,
573    best_list: BestMatchesList,
574    total_match_count: usize,
575    limit: usize,
576}
577
578impl Drop for SearchWorkerGuard {
579    fn drop(&mut self) {
580        let worker_matches = std::mem::take(&mut self.best_list.owned_matches);
581        let mut results = self.results.lock();
582        results.total_match_count += self.total_match_count;
583        for Reverse(candidate) in worker_matches {
584            push_owned_match(&mut results.matches, self.limit, candidate);
585        }
586    }
587}
588
589/// Stores a pattern in the optimal form for Utf32Str creation.
590enum PatternStorage {
591    /// ASCII pattern - can be used directly with Utf32Str::Ascii
592    Ascii(Vec<u8>),
593    /// Unicode pattern - stored as chars for Utf32Str::Unicode
594    Unicode(Vec<char>),
595}
596
597impl BestMatchesList {
598    fn new(limit: usize, pattern_text: &str) -> Self {
599        // Normalize pattern to lowercase to work around a nucleo-matcher bug:
600        // its prefilter only does case-insensitive search for lowercase needle
601        // chars, not uppercase. See https://github.com/openai/codex/pull/15772.
602        let pattern = if pattern_text.is_ascii() {
603            PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
604        } else {
605            PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
606        };
607
608        Self {
609            matches: BinaryHeap::new(),
610            limit,
611            matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
612            haystack_buf: Vec::with_capacity(256),
613            pattern,
614            owned_matches: BinaryHeap::new(),
615        }
616    }
617
618    /// Score a path using the pre-computed pattern without allocating a
619    /// temporary string.
620    fn score_path(&mut self, path: &str) -> Option<u32> {
621        let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
622        let needle = match &self.pattern {
623            PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
624            PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
625        };
626        self.matcher.fuzzy_match(haystack, needle).map(|score| score as u32)
627    }
628
629    /// Record a matching path with an already-known [`StringId`].
630    fn record_match(&mut self, path_id: StringId, path: &str, match_type: MatchType) -> bool {
631        let Some(score) = self.score_path(path) else {
632            return false;
633        };
634        push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
635        true
636    }
637
638    fn record_scored_match(&mut self, path_id: StringId, score: u32, match_type: MatchType) {
639        push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
640    }
641
642    fn record_owned_match(&mut self, path: &str, score: u32, match_type: MatchType) {
643        if self.owned_matches.len() == self.limit {
644            let Some(Reverse(worst)) = self.owned_matches.peek() else {
645                return;
646            };
647            if !owned_match_is_better(score, path, match_type, worst) {
648                return;
649            }
650        }
651
652        push_owned_match(
653            &mut self.owned_matches,
654            self.limit,
655            OwnedMatchCandidate { score, path: path.to_owned(), match_type },
656        );
657    }
658}
659
660fn push_owned_match(
661    matches: &mut BinaryHeap<Reverse<OwnedMatchCandidate>>,
662    limit: usize,
663    candidate: OwnedMatchCandidate,
664) {
665    if matches.len() == limit {
666        let Some(Reverse(worst)) = matches.peek() else {
667            return;
668        };
669        if !owned_match_is_better(candidate.score, candidate.path.as_str(), candidate.match_type, worst) {
670            return;
671        }
672        matches.pop();
673    }
674
675    matches.push(Reverse(candidate));
676}
677
678fn owned_match_is_better(score: u32, path: &str, match_type: MatchType, worst: &OwnedMatchCandidate) -> bool {
679    score
680        .cmp(&worst.score)
681        .then_with(|| worst.path.as_str().cmp(path))
682        .then_with(|| worst.match_type.cmp(&match_type))
683        .is_gt()
684}
685
686fn push_top_match(
687    matches: &mut BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
688    limit: usize,
689    score: u32,
690    path: StringId,
691    match_type: MatchType,
692) -> bool {
693    let candidate = (score, path, match_type);
694    if matches.len() < limit {
695        matches.push(Reverse(candidate));
696        return true;
697    }
698
699    let Some(minimum) = matches.peek().map(|entry| &entry.0) else {
700        return false;
701    };
702
703    if &candidate <= minimum {
704        return false;
705    }
706
707    matches.pop();
708    matches.push(Reverse(candidate));
709    true
710}
711
712/// Run fuzzy file search using a pre-computed file index.
713///
714/// This is much faster than `run()` for repeated queries on the same
715/// directory because it avoids re-traversing the filesystem.
716///
717/// # Arguments
718///
719/// * `config` - File search configuration
720/// * `index_cache` - Shared cache for the pre-computed file index
721///
722/// # Returns
723///
724/// FileSearchResults containing matched files and total match count.
725pub async fn run_with_index(
726    config: FileSearchConfig,
727    index_cache: &FileIndexCache,
728) -> anyhow::Result<FileSearchResults> {
729    let limit = config.limit.get();
730    let cancel_flag = &config.cancel_flag;
731    let compute_indices = config.compute_indices;
732
733    // Get or build the file index
734    let index = index_cache.get_or_build().await?;
735
736    // Check cancellation
737    if cancel_flag.load(Ordering::Relaxed) {
738        return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
739    }
740
741    // Query the index off the async runtime thread to avoid stalling
742    // the tokio worker while rayon parallel-scoring runs.
743    let index_for_results = index.clone();
744    let matched_paths = tokio::task::spawn_blocking({
745        let pattern_text = config.pattern_text.clone();
746        move || Ok::<_, anyhow::Error>(index.query(&pattern_text, limit, None))
747    })
748    .await??;
749
750    let total_match_count = matched_paths.len();
751
752    // Build final results
753    let matches = matched_paths
754        .into_iter()
755        .filter_map(|(score, path_id, match_type)| {
756            let path = index_for_results.path_texts_by_id.get(path_id.as_u32() as usize)?.to_string();
757            Some(FileMatch {
758                score,
759                path,
760                match_type,
761                indices: if compute_indices { Some(Vec::new()) } else { None },
762            })
763        })
764        .collect();
765
766    Ok(FileSearchResults { matches, total_match_count })
767}
768
769/// Run fuzzy file search with parallel traversal.
770///
771/// # Arguments
772///
773/// * `config` - File search configuration containing all search parameters
774///
775/// # Returns
776///
777/// FileSearchResults containing matched files and total match count.
778pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
779    run_with_policy(config, true, false)
780}
781
782/// Run a bounded fuzzy path search without following symbolic links.
783///
784/// This focused route is intended for request-scoped code search. It traverses
785/// eligible paths in deterministic order and stops at the candidate cap. It
786/// deliberately avoids the persistent [`FileIndexCache`].
787pub fn run_bounded_no_follow(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
788    run_bounded_no_follow_with_visit(config, |_| {})
789}
790
791fn run_bounded_no_follow_with_visit(
792    config: FileSearchConfig,
793    mut visit: impl FnMut(&Path),
794) -> anyhow::Result<FileSearchResults> {
795    let limit = config.limit.get();
796    let search_directory = &config.search_directory;
797    let mut walk_builder = ignore::WalkBuilder::new(search_directory);
798    vtcode_commons::walk::apply_defaults(&mut walk_builder);
799    walk_builder
800        .follow_links(false)
801        .require_git(false)
802        .sort_by_file_path(|left, right| left.cmp(right));
803
804    if !config.respect_gitignore {
805        walk_builder
806            .git_ignore(false)
807            .git_global(false)
808            .git_exclude(false)
809            .ignore(false)
810            .parents(false);
811    }
812
813    if !config.exclude.is_empty() {
814        let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
815        for exclude_pattern in &config.exclude {
816            override_builder.add(&format!("!{exclude_pattern}"))?;
817        }
818        walk_builder.overrides(override_builder.build()?);
819    }
820
821    let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
822    let mut matches = BestMatchesList::new(limit, &config.pattern_text);
823    let mut matching_count = 0usize;
824    for result in walk_builder.build() {
825        if config.cancel_flag.load(Ordering::Relaxed) {
826            break;
827        }
828        let entry = match result {
829            Ok(entry) => entry,
830            Err(_) => continue,
831        };
832        visit(entry.path());
833        if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
834            continue;
835        }
836        let Some(relative_path) = entry
837            .path()
838            .strip_prefix(search_directory)
839            .ok()
840            .and_then(|path| path.to_str())
841            .filter(|path| !path.is_empty())
842        else {
843            continue;
844        };
845        let Some(score) = matches.score_path(relative_path) else {
846            continue;
847        };
848        let path_id = interner.lock().intern(relative_path);
849        matches.record_scored_match(path_id, score, MatchType::File);
850        matching_count += 1;
851        if matching_count >= limit {
852            break;
853        }
854    }
855
856    let interner_guard = interner.lock();
857    let matches = matches
858        .matches
859        .into_sorted_vec()
860        .into_iter()
861        .filter_map(|Reverse((score, path_id, match_type))| {
862            let path = interner_guard.get(path_id)?.to_string();
863            Some(FileMatch {
864                score,
865                path,
866                match_type,
867                indices: config.compute_indices.then(Vec::new),
868            })
869        })
870        .collect();
871
872    Ok(FileSearchResults {
873        matches,
874        // Reaching the cap terminates traversal, so report conservative
875        // truncation without scanning the rest of the tree for an exact total.
876        total_match_count: matching_count + usize::from(matching_count >= limit),
877    })
878}
879
880fn run_with_policy(
881    config: FileSearchConfig,
882    follow_links: bool,
883    files_only: bool,
884) -> anyhow::Result<FileSearchResults> {
885    let limit = config.limit.get();
886    let search_directory = &config.search_directory;
887    let exclude = &config.exclude;
888    let threads = config.threads.get();
889    let cancel_flag = &config.cancel_flag;
890    let compute_indices = config.compute_indices;
891    let respect_gitignore = config.respect_gitignore;
892
893    let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, follow_links)?;
894
895    let worker_results = Arc::new(Mutex::new(SearchAggregation { matches: BinaryHeap::new(), total_match_count: 0 }));
896    walker.run(|| {
897        let mut state = SearchWorkerGuard {
898            results: Arc::clone(&worker_results),
899            best_list: BestMatchesList::new(limit, &config.pattern_text),
900            total_match_count: 0,
901            limit,
902        };
903
904        Box::new(move |result| {
905            // Check cancellation flag periodically
906            if cancel_flag.load(Ordering::Relaxed) {
907                return ignore::WalkState::Quit;
908            }
909
910            let entry = match result {
911                Ok(e) => e,
912                Err(_) => return ignore::WalkState::Continue,
913            };
914
915            // Make path relative to search directory
916            let relative_path = entry.path().strip_prefix(search_directory).ok().and_then(|p| p.to_str());
917
918            let path_to_match = match relative_path {
919                Some(p) if !p.is_empty() => p,
920                _ => return ignore::WalkState::Continue, // Skip root and non-relative paths
921            };
922
923            let Some(file_type) = entry.file_type() else {
924                return ignore::WalkState::Continue;
925            };
926            let match_type = if file_type.is_dir() {
927                MatchType::Directory
928            } else {
929                MatchType::File
930            };
931
932            if files_only && match_type == MatchType::Directory {
933                return ignore::WalkState::Continue;
934            }
935
936            // Try to add to results - no contention with other workers
937            let Some(score) = state.best_list.score_path(path_to_match) else {
938                return ignore::WalkState::Continue;
939            };
940            state.best_list.record_owned_match(path_to_match, score, match_type);
941            state.total_match_count += 1;
942
943            ignore::WalkState::Continue
944        })
945    });
946
947    let results = Arc::try_unwrap(worker_results)
948        .map_err(|results| {
949            anyhow::anyhow!("file search worker results still referenced: {}", Arc::strong_count(&results))
950        })?
951        .into_inner();
952    let total_match_count = results.total_match_count;
953    let mut candidates = results
954        .matches
955        .into_iter()
956        .map(|Reverse(candidate)| candidate)
957        .collect::<Vec<_>>();
958    candidates.sort_by(|left, right| {
959        right
960            .score
961            .cmp(&left.score)
962            .then_with(|| left.path.cmp(&right.path))
963            .then_with(|| left.match_type.cmp(&right.match_type))
964    });
965    candidates.truncate(limit);
966
967    // Build final results
968    let matches = candidates
969        .into_iter()
970        .map(|candidate| FileMatch {
971            score: candidate.score,
972            path: candidate.path,
973            match_type: candidate.match_type,
974            indices: if compute_indices { Some(Vec::new()) } else { None },
975        })
976        .collect();
977
978    Ok(FileSearchResults { matches, total_match_count })
979}
980
981#[cfg(test)]
982mod tests;