Skip to main content

vtcode_core/tools/
file_search_bridge.rs

1//! Bridge module integrating vtcode-indexer::file_search with grep_file.rs
2//!
3//! This module provides a clean interface to use the file-search module
4//! for file discovery operations, replacing direct ripgrep subprocess
5//! calls for file enumeration.
6//!
7//! It handles:
8//! - Converting between vtcode-core and vtcode-indexer::file_search APIs
9//! - Integrating file search results with existing grep workflows
10
11use anyhow::{Context, Result};
12use std::num::NonZero;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::atomic::AtomicBool;
16use vtcode_indexer::file_search::{
17    FileMatch, FileSearchResults, MatchType, file_name_from_path, run as file_search_run, run_bounded_no_follow,
18};
19
20pub use vtcode_indexer::file_search::MatchType as FileMatchType;
21
22/// Maximum number of paths returned by the file-search bridge.
23///
24/// The bridge is used by request-facing integrations, so keeping the limit
25/// bounded also bounds the per-worker fuzzy-search heaps and owned path text.
26pub const MAX_FILE_SEARCH_RESULTS: usize = 1_000;
27
28/// Normalize an externally supplied result limit before it reaches the
29/// parallel indexer.
30pub const fn effective_max_results(requested: usize) -> usize {
31    if requested == 0 {
32        1
33    } else if requested > MAX_FILE_SEARCH_RESULTS {
34        MAX_FILE_SEARCH_RESULTS
35    } else {
36        requested
37    }
38}
39
40/// Configuration for file search operations
41#[derive(Debug, Clone)]
42pub struct FileSearchConfig {
43    /// Search pattern (fuzzy)
44    pub pattern: String,
45    /// Root directory to search
46    pub search_dir: PathBuf,
47    /// Patterns to exclude (glob-style)
48    pub exclude_patterns: Vec<String>,
49    /// Maximum number of results
50    pub max_results: usize,
51    /// Number of worker threads
52    pub num_threads: usize,
53    /// Whether to respect .gitignore files
54    pub respect_gitignore: bool,
55    /// Whether to compute character indices for highlighting
56    pub compute_indices: bool,
57}
58
59impl FileSearchConfig {
60    /// Create a new file search configuration
61    pub fn new(pattern: String, search_dir: PathBuf) -> Self {
62        Self {
63            pattern,
64            search_dir,
65            exclude_patterns: vec![],
66            max_results: 100,
67            num_threads: num_cpus::get(),
68            respect_gitignore: true,
69            compute_indices: false,
70        }
71    }
72
73    /// Add an exclusion pattern
74    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
75        self.exclude_patterns.push(pattern.into());
76        self
77    }
78
79    /// Set maximum number of results
80    pub fn with_limit(mut self, limit: usize) -> Self {
81        self.max_results = effective_max_results(limit);
82        self
83    }
84
85    /// Set number of threads
86    pub fn with_threads(mut self, threads: usize) -> Self {
87        self.num_threads = threads.max(1);
88        self
89    }
90
91    /// Enable/disable .gitignore support
92    pub fn respect_gitignore(mut self, respect: bool) -> Self {
93        self.respect_gitignore = respect;
94        self
95    }
96
97    /// Enable character indices for highlighting
98    pub fn compute_indices(mut self, compute: bool) -> Self {
99        self.compute_indices = compute;
100        self
101    }
102}
103
104/// Search for files matching a pattern
105///
106/// # Arguments
107///
108/// * `config` - File search configuration
109/// * `cancel_flag` - Optional cancellation flag for early termination
110///
111/// # Returns
112///
113/// FileSearchResults containing matched files
114pub fn search_files(config: FileSearchConfig, cancel_flag: Option<Arc<AtomicBool>>) -> Result<FileSearchResults> {
115    let cancel = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
116
117    let limit = NonZero::new(effective_max_results(config.max_results)).unwrap_or(NonZero::<usize>::MIN);
118    let threads = NonZero::new(config.num_threads).unwrap_or(NonZero::<usize>::MIN);
119
120    file_search_run(vtcode_indexer::file_search::FileSearchConfig {
121        pattern_text: config.pattern,
122        limit,
123        search_directory: config.search_dir,
124        exclude: config.exclude_patterns,
125        threads,
126        cancel_flag: cancel,
127        compute_indices: config.compute_indices,
128        respect_gitignore: config.respect_gitignore,
129    })
130}
131
132/// Search for files without blocking the Tokio worker running the caller.
133pub async fn search_files_async(
134    config: FileSearchConfig,
135    cancel_flag: Option<Arc<AtomicBool>>,
136) -> Result<FileSearchResults> {
137    tokio::task::spawn_blocking(move || search_files(config, cancel_flag))
138        .await
139        .context("file search task panicked")?
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub(crate) struct BoundedPathSearch {
144    pub paths: Vec<PathBuf>,
145    pub truncated: bool,
146}
147
148/// Retrieve bounded fuzzy path candidates without exposing scores or following
149/// symbolic links.
150pub(crate) fn search_paths_bounded_no_follow(
151    pattern: &str,
152    search_dir: PathBuf,
153    candidate_cap: usize,
154) -> Result<BoundedPathSearch> {
155    let limit = NonZero::new(candidate_cap).unwrap_or(NonZero::<usize>::MIN);
156    let results = run_bounded_no_follow(vtcode_indexer::file_search::FileSearchConfig {
157        pattern_text: pattern.to_string(),
158        limit,
159        search_directory: search_dir,
160        exclude: Vec::new(),
161        threads: NonZero::<usize>::MIN,
162        cancel_flag: Arc::new(AtomicBool::new(false)),
163        compute_indices: false,
164        respect_gitignore: true,
165    })?;
166    let truncated = results.total_match_count > candidate_cap;
167    let paths = results
168        .matches
169        .into_iter()
170        .filter(|candidate| candidate.match_type == MatchType::File)
171        .map(|candidate| PathBuf::from(candidate.path))
172        .collect();
173
174    Ok(BoundedPathSearch { paths, truncated })
175}
176
177/// Get filename from a file match
178///
179/// Convenience wrapper around `file_name_from_path`
180pub fn match_filename(file_match: &FileMatch) -> String {
181    file_name_from_path(&file_match.path)
182}
183
184/// Keep only file matches, dropping directory entries.
185pub fn file_matches_only(matches: Vec<FileMatch>) -> Vec<FileMatch> {
186    matches
187        .into_iter()
188        .filter(|m| matches!(m.match_type, MatchType::File))
189        .collect()
190}
191
192/// Filter file matches by file extension
193///
194/// # Arguments
195///
196/// * `matches` - Vector of file matches
197/// * `extensions` - File extensions to keep (e.g., ["rs", "toml"])
198pub fn filter_by_extension(matches: Vec<FileMatch>, extensions: &[&str]) -> Vec<FileMatch> {
199    matches
200        .into_iter()
201        .filter(|m| {
202            extensions
203                .iter()
204                .any(|ext| m.path.ends_with(&format!(".{ext}")) || m.path.ends_with(ext))
205        })
206        .collect()
207}
208
209/// Filter file matches by path pattern
210///
211/// # Arguments
212///
213/// * `matches` - Vector of file matches
214/// * `path_pattern` - Glob pattern to match against paths
215pub fn filter_by_pattern(matches: Vec<FileMatch>, path_pattern: &str) -> Vec<FileMatch> {
216    if let Ok(pattern) = glob::Pattern::new(path_pattern) {
217        matches.into_iter().filter(|m| pattern.matches(&m.path)).collect()
218    } else {
219        matches
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[cfg(unix)]
228    #[test]
229    fn bounded_path_search_does_not_follow_directory_symlinks() {
230        use std::fs;
231        use std::os::unix::fs::symlink;
232        use tempfile::TempDir;
233
234        let workspace = TempDir::new().expect("workspace");
235        let outside = TempDir::new().expect("outside directory");
236        fs::write(workspace.path().join("WidgetLocal.rs"), "").expect("local file");
237        fs::write(outside.path().join("WidgetOutside.rs"), "").expect("outside file");
238        symlink(outside.path(), workspace.path().join("linked")).expect("directory symlink fixture");
239
240        let outcome =
241            search_paths_bounded_no_follow("Widget", workspace.path().to_path_buf(), 20).expect("bounded path search");
242
243        assert!(outcome.paths.contains(&PathBuf::from("WidgetLocal.rs")));
244        assert!(outcome.paths.iter().all(|path| !path.ends_with("WidgetOutside.rs")));
245    }
246
247    #[test]
248    fn bounded_path_search_stops_at_candidate_cap() {
249        let workspace = tempfile::TempDir::new().expect("workspace");
250        for index in 0..5 {
251            std::fs::write(workspace.path().join(format!("Widget{index}.rs")), "").expect("fixture file");
252        }
253
254        let outcome =
255            search_paths_bounded_no_follow("Widget", workspace.path().to_path_buf(), 2).expect("bounded path search");
256
257        assert_eq!(outcome.paths.len(), 2);
258        assert!(outcome.truncated);
259    }
260
261    #[test]
262    fn bounded_path_search_does_not_spend_file_capacity_on_directories() {
263        let workspace = tempfile::TempDir::new().expect("workspace");
264        let nested = workspace.path().join("WidgetParent").join("WidgetChild");
265        std::fs::create_dir_all(&nested).expect("matching directories");
266        std::fs::write(nested.join("TargetWidget.rs"), "").expect("matching file");
267
268        let outcome =
269            search_paths_bounded_no_follow("Widget", workspace.path().to_path_buf(), 1).expect("bounded path search");
270
271        assert_eq!(outcome.paths.len(), 1);
272        assert!(outcome.paths[0].ends_with("TargetWidget.rs"));
273    }
274
275    #[test]
276    fn test_file_search_config_builder() {
277        let config = FileSearchConfig::new("test".to_string(), PathBuf::from("."))
278            .exclude("target/**")
279            .with_limit(50)
280            .with_threads(4);
281
282        assert_eq!(config.pattern, "test");
283        assert_eq!(config.max_results, 50);
284        assert_eq!(config.num_threads, 4);
285        assert_eq!(config.exclude_patterns.len(), 1);
286    }
287
288    #[test]
289    fn test_match_filename() {
290        let file_match = FileMatch {
291            score: 100,
292            path: "src/utils/helper.rs".to_string(),
293            match_type: MatchType::File,
294            indices: None,
295        };
296
297        assert_eq!(match_filename(&file_match), "helper.rs");
298    }
299
300    #[test]
301    fn test_filter_by_extension() {
302        let matches = vec![
303            FileMatch {
304                score: 100,
305                path: "src/main.rs".to_string(),
306                match_type: MatchType::File,
307                indices: None,
308            },
309            FileMatch {
310                score: 90,
311                path: "src/config.toml".to_string(),
312                match_type: MatchType::File,
313                indices: None,
314            },
315            FileMatch {
316                score: 80,
317                path: "src/data.json".to_string(),
318                match_type: MatchType::File,
319                indices: None,
320            },
321        ];
322
323        let filtered = filter_by_extension(matches, &["rs", "toml"]);
324        assert_eq!(filtered.len(), 2);
325        assert!(
326            filtered
327                .iter()
328                .all(|m| { m.path.ends_with(".rs") || m.path.ends_with(".toml") })
329        );
330    }
331
332    #[test]
333    fn test_file_matches_only_filters_directories() {
334        let matches = vec![
335            FileMatch {
336                score: 100,
337                path: "src".to_string(),
338                match_type: MatchType::Directory,
339                indices: None,
340            },
341            FileMatch {
342                score: 90,
343                path: "src/main.rs".to_string(),
344                match_type: MatchType::File,
345                indices: None,
346            },
347        ];
348
349        let filtered = file_matches_only(matches);
350
351        assert_eq!(filtered.len(), 1);
352        assert!(matches!(filtered[0].match_type, MatchType::File));
353    }
354}