1use 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
22pub const MAX_FILE_SEARCH_RESULTS: usize = 1_000;
27
28pub 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#[derive(Debug, Clone)]
42pub struct FileSearchConfig {
43 pub pattern: String,
45 pub search_dir: PathBuf,
47 pub exclude_patterns: Vec<String>,
49 pub max_results: usize,
51 pub num_threads: usize,
53 pub respect_gitignore: bool,
55 pub compute_indices: bool,
57}
58
59impl FileSearchConfig {
60 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 pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
75 self.exclude_patterns.push(pattern.into());
76 self
77 }
78
79 pub fn with_limit(mut self, limit: usize) -> Self {
81 self.max_results = effective_max_results(limit);
82 self
83 }
84
85 pub fn with_threads(mut self, threads: usize) -> Self {
87 self.num_threads = threads.max(1);
88 self
89 }
90
91 pub fn respect_gitignore(mut self, respect: bool) -> Self {
93 self.respect_gitignore = respect;
94 self
95 }
96
97 pub fn compute_indices(mut self, compute: bool) -> Self {
99 self.compute_indices = compute;
100 self
101 }
102}
103
104pub 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
132pub 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
148pub(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
177pub fn match_filename(file_match: &FileMatch) -> String {
181 file_name_from_path(&file_match.path)
182}
183
184pub 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
192pub 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
209pub 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}