Skip to main content

vtcode_core/tools/
file_search_bridge.rs

1//! Bridge module integrating vtcode-file-search with grep_file.rs
2//!
3//! This module provides a clean interface to use the dedicated file-search
4//! crate for file discovery operations, replacing direct ripgrep subprocess
5//! calls for file enumeration.
6//!
7//! It handles:
8//! - Converting between vtcode-core and vtcode-file-search APIs
9//! - Integrating file search results with existing grep workflows
10//! - Maintaining backward compatibility during transition
11
12use anyhow::Result;
13use std::num::NonZero;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::sync::atomic::AtomicBool;
17use vtcode_file_search::{
18    FileMatch, FileSearchResults, MatchType, file_name_from_path, run as file_search_run,
19};
20
21pub use vtcode_file_search::MatchType as FileMatchType;
22
23/// Configuration for file search operations
24#[derive(Debug, Clone)]
25pub struct FileSearchConfig {
26    /// Search pattern (fuzzy)
27    pub pattern: String,
28    /// Root directory to search
29    pub search_dir: PathBuf,
30    /// Patterns to exclude (glob-style)
31    pub exclude_patterns: Vec<String>,
32    /// Maximum number of results
33    pub max_results: usize,
34    /// Number of worker threads
35    pub num_threads: usize,
36    /// Whether to respect .gitignore files
37    pub respect_gitignore: bool,
38    /// Whether to compute character indices for highlighting
39    pub compute_indices: bool,
40}
41
42impl FileSearchConfig {
43    /// Create a new file search configuration
44    pub fn new(pattern: String, search_dir: PathBuf) -> Self {
45        Self {
46            pattern,
47            search_dir,
48            exclude_patterns: vec![],
49            max_results: 100,
50            num_threads: num_cpus::get(),
51            respect_gitignore: true,
52            compute_indices: false,
53        }
54    }
55
56    /// Add an exclusion pattern
57    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
58        self.exclude_patterns.push(pattern.into());
59        self
60    }
61
62    /// Set maximum number of results
63    pub fn with_limit(mut self, limit: usize) -> Self {
64        self.max_results = limit;
65        self
66    }
67
68    /// Set number of threads
69    pub fn with_threads(mut self, threads: usize) -> Self {
70        self.num_threads = threads.max(1);
71        self
72    }
73
74    /// Enable/disable .gitignore support
75    pub fn respect_gitignore(mut self, respect: bool) -> Self {
76        self.respect_gitignore = respect;
77        self
78    }
79
80    /// Enable character indices for highlighting
81    pub fn compute_indices(mut self, compute: bool) -> Self {
82        self.compute_indices = compute;
83        self
84    }
85}
86
87/// Search for files matching a pattern
88///
89/// # Arguments
90///
91/// * `config` - File search configuration
92/// * `cancel_flag` - Optional cancellation flag for early termination
93///
94/// # Returns
95///
96/// FileSearchResults containing matched files
97pub fn search_files(
98    config: FileSearchConfig,
99    cancel_flag: Option<Arc<AtomicBool>>,
100) -> Result<FileSearchResults> {
101    let cancel = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
102
103    let limit = NonZero::new(config.max_results).unwrap_or(NonZero::<usize>::MIN);
104    let threads = NonZero::new(config.num_threads).unwrap_or(NonZero::<usize>::MIN);
105
106    file_search_run(vtcode_file_search::FileSearchConfig {
107        pattern_text: config.pattern,
108        limit,
109        search_directory: config.search_dir,
110        exclude: config.exclude_patterns,
111        threads,
112        cancel_flag: cancel,
113        compute_indices: config.compute_indices,
114        respect_gitignore: config.respect_gitignore,
115    })
116}
117
118/// Get filename from a file match
119///
120/// Convenience wrapper around `file_name_from_path`
121pub fn match_filename(file_match: &FileMatch) -> String {
122    file_name_from_path(&file_match.path)
123}
124
125/// Keep only file matches, dropping directory entries.
126pub fn file_matches_only(matches: Vec<FileMatch>) -> Vec<FileMatch> {
127    matches
128        .into_iter()
129        .filter(|m| matches!(m.match_type, MatchType::File))
130        .collect()
131}
132
133/// Filter file matches by file extension
134///
135/// # Arguments
136///
137/// * `matches` - Vector of file matches
138/// * `extensions` - File extensions to keep (e.g., ["rs", "toml"])
139pub fn filter_by_extension(matches: Vec<FileMatch>, extensions: &[&str]) -> Vec<FileMatch> {
140    matches
141        .into_iter()
142        .filter(|m| {
143            extensions
144                .iter()
145                .any(|ext| m.path.ends_with(&format!(".{}", ext)) || m.path.ends_with(ext))
146        })
147        .collect()
148}
149
150/// Filter file matches by path pattern
151///
152/// # Arguments
153///
154/// * `matches` - Vector of file matches
155/// * `path_pattern` - Glob pattern to match against paths
156pub fn filter_by_pattern(matches: Vec<FileMatch>, path_pattern: &str) -> Vec<FileMatch> {
157    if let Ok(pattern) = glob::Pattern::new(path_pattern) {
158        matches
159            .into_iter()
160            .filter(|m| pattern.matches(&m.path))
161            .collect()
162    } else {
163        matches
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_file_search_config_builder() {
173        let config = FileSearchConfig::new("test".to_string(), PathBuf::from("."))
174            .exclude("target/**")
175            .with_limit(50)
176            .with_threads(4);
177
178        assert_eq!(config.pattern, "test");
179        assert_eq!(config.max_results, 50);
180        assert_eq!(config.num_threads, 4);
181        assert_eq!(config.exclude_patterns.len(), 1);
182    }
183
184    #[test]
185    fn test_match_filename() {
186        let file_match = FileMatch {
187            score: 100,
188            path: "src/utils/helper.rs".to_string(),
189            match_type: MatchType::File,
190            indices: None,
191        };
192
193        assert_eq!(match_filename(&file_match), "helper.rs");
194    }
195
196    #[test]
197    fn test_filter_by_extension() {
198        let matches = vec![
199            FileMatch {
200                score: 100,
201                path: "src/main.rs".to_string(),
202                match_type: MatchType::File,
203                indices: None,
204            },
205            FileMatch {
206                score: 90,
207                path: "src/config.toml".to_string(),
208                match_type: MatchType::File,
209                indices: None,
210            },
211            FileMatch {
212                score: 80,
213                path: "src/data.json".to_string(),
214                match_type: MatchType::File,
215                indices: None,
216            },
217        ];
218
219        let filtered = filter_by_extension(matches, &["rs", "toml"]);
220        assert_eq!(filtered.len(), 2);
221        assert!(
222            filtered
223                .iter()
224                .all(|m| { m.path.ends_with(".rs") || m.path.ends_with(".toml") })
225        );
226    }
227
228    #[test]
229    fn test_file_matches_only_filters_directories() {
230        let matches = vec![
231            FileMatch {
232                score: 100,
233                path: "src".to_string(),
234                match_type: MatchType::Directory,
235                indices: None,
236            },
237            FileMatch {
238                score: 90,
239                path: "src/main.rs".to_string(),
240                match_type: MatchType::File,
241                indices: None,
242            },
243        ];
244
245        let filtered = file_matches_only(matches);
246
247        assert_eq!(filtered.len(), 1);
248        assert!(matches!(filtered[0].match_type, MatchType::File));
249    }
250}