1use 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
45pub 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
56fn 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 walk_builder.threads(threads);
69 walk_builder.follow_links(follow_links);
70 walk_builder.require_git(false); 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 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 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 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 fn query(
158 &self,
159 pattern_text: &str,
160 limit: usize,
161 match_type_filter: Option<MatchType>,
162 ) -> Vec<(u32, StringId, MatchType)> {
163 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
185fn 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 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
233fn 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
252pub struct FileIndexCache {
254 cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
255 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 pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
283 {
285 let guard = self.cache.read().await;
286 if let Some(index) = guard.as_ref() {
287 if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
289 return Ok(Arc::clone(index));
290 }
291 }
292 }
293
294 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 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 {
322 let mut guard = self.cache.write().await;
323 *guard = Some(Arc::clone(&index));
324 }
325 Ok(index)
326 }
327
328 pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
331 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 let guard = self.cache.blocking_read();
368 guard.as_ref().map(Arc::clone)
369 }
370
371 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 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
400impl 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#[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#[derive(Debug)]
437pub struct FileSearchResults {
438 pub matches: Vec<FileMatch>,
439 pub total_match_count: usize,
440}
441
442pub 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
456struct BestMatchesList {
461 matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
462 limit: usize,
463 matcher: nucleo_matcher::Matcher,
464 haystack_buf: Vec<char>,
465 pattern: PatternStorage,
467 interner: Arc<Mutex<vtcode_commons::StringInterner>>,
468}
469
470enum PatternStorage {
472 Ascii(Vec<u8>),
474 Unicode(Vec<char>),
476}
477
478impl BestMatchesList {
479 fn new(limit: usize, pattern_text: &str, interner: &Arc<Mutex<vtcode_commons::StringInterner>>) -> Self {
480 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 fn record_match(&mut self, path: &str, match_type: MatchType) -> bool {
504 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
546pub 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 let index = index_cache.get_or_build().await?;
569
570 if cancel_flag.load(Ordering::Relaxed) {
572 return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
573 }
574
575 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 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
603pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
613 run_with_policy(config, true, false)
614}
615
616pub 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 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 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 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 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 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, };
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 {
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 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 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}