Skip to main content

vtcode_indexer/
file_search.rs

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