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