1use arc_swap::ArcSwapOption;
33use parking_lot::Mutex;
34use serde::{Deserialize, Serialize};
35use std::cmp::Reverse;
36use std::collections::BinaryHeap;
37use std::num::NonZero;
38use std::path::Path;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
41use tokio::sync::RwLock;
42
43use rayon::prelude::*;
44use vtcode_commons::StringId;
45
46pub struct FileIndex {
51 files: Vec<StringId>,
52 directories: Vec<StringId>,
53 path_texts_by_id: Arc<Vec<Arc<str>>>,
57 interner: vtcode_commons::StringInterner,
59 last_built: std::time::Instant,
60}
61
62fn build_parallel_walker(
64 search_directory: &Path,
65 exclude: &[String],
66 threads: usize,
67 respect_gitignore: bool,
68 follow_links: bool,
69) -> anyhow::Result<ignore::WalkParallel> {
70 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
71 vtcode_commons::walk::apply_defaults(&mut walk_builder);
72
73 walk_builder.threads(threads);
75 walk_builder.follow_links(follow_links);
76 walk_builder.require_git(false); if !respect_gitignore {
79 walk_builder
80 .git_ignore(false)
81 .git_global(false)
82 .git_exclude(false)
83 .ignore(false)
84 .parents(false);
85 }
86
87 if !exclude.is_empty() {
88 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
89 for exclude_pattern in exclude {
90 let pattern = format!("!{exclude_pattern}");
91 override_builder.add(&pattern)?;
92 }
93 walk_builder.overrides(override_builder.build()?);
94 }
95
96 Ok(walk_builder.build_parallel())
97}
98
99impl FileIndex {
100 fn build_from_directory(
103 search_directory: &Path,
104 exclude: &[String],
105 respect_gitignore: bool,
106 threads: usize,
107 ) -> anyhow::Result<Self> {
108 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, true)?;
109
110 let files_arc = Arc::new(Mutex::new(Vec::new()));
112 let dirs_arc = Arc::new(Mutex::new(Vec::new()));
113
114 walker.run(|| {
115 let files_clone = files_arc.clone();
116 let dirs_clone = dirs_arc.clone();
117 let search_dir = search_directory.to_path_buf();
118
119 Box::new(move |result| {
120 let entry = match result {
121 Ok(e) => e,
122 Err(_) => return ignore::WalkState::Continue,
123 };
124
125 if let Some(rel_path) = entry.path().strip_prefix(&search_dir).ok().and_then(|p| p.to_str())
127 && !rel_path.is_empty()
128 {
129 if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
130 dirs_clone.lock().push(rel_path.to_string());
131 } else {
132 files_clone.lock().push(rel_path.to_string());
133 }
134 }
135
136 ignore::WalkState::Continue
137 })
138 });
139
140 let files = Arc::try_unwrap(files_arc)
141 .map_err(|arc| {
142 anyhow::anyhow!("failed to unwrap files arc, {} references remain", Arc::strong_count(&arc))
143 })?
144 .into_inner();
145 let directories = Arc::try_unwrap(dirs_arc)
146 .map_err(|arc| anyhow::anyhow!("failed to unwrap dirs arc, {} references remain", Arc::strong_count(&arc)))?
147 .into_inner();
148
149 let mut interner = vtcode_commons::StringInterner::new();
150 let mut path_texts_by_id = Vec::with_capacity(files.len() + directories.len());
151 let interned_files: Vec<StringId> = files
152 .iter()
153 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
154 .collect();
155 let interned_dirs: Vec<StringId> = directories
156 .iter()
157 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
158 .collect();
159
160 Ok(Self {
161 files: interned_files,
162 directories: interned_dirs,
163 path_texts_by_id: Arc::new(path_texts_by_id),
164 interner,
165 last_built: std::time::Instant::now(),
166 })
167 }
168
169 fn query(
172 &self,
173 pattern_text: &str,
174 limit: usize,
175 match_type_filter: Option<MatchType>,
176 ) -> Vec<(u32, StringId, MatchType)> {
177 let mut heaps = Vec::new();
182
183 if match_type_filter.is_none_or(|t| t == MatchType::File) {
184 heaps.push(score_paths_top_k(
185 &self.files,
186 self.path_texts_by_id.as_slice(),
187 limit,
188 pattern_text,
189 MatchType::File,
190 ));
191 }
192
193 if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
194 heaps.push(score_paths_top_k(
195 &self.directories,
196 self.path_texts_by_id.as_slice(),
197 limit,
198 pattern_text,
199 MatchType::Directory,
200 ));
201 }
202
203 merge_top_k(heaps, limit)
204 .into_sorted_vec()
205 .into_iter()
206 .map(|Reverse(item)| item)
207 .collect()
208 }
209}
210
211fn intern_path(
212 path: &str,
213 interner: &mut vtcode_commons::StringInterner,
214 path_texts_by_id: &mut Vec<Arc<str>>,
215) -> StringId {
216 let path_id = interner.intern(path);
217 let path_index = path_id.as_u32() as usize;
218 if path_index == path_texts_by_id.len() {
219 path_texts_by_id.push(Arc::from(path));
220 }
221 path_id
222}
223
224fn score_paths_top_k(
232 paths: &[StringId],
233 path_texts_by_id: &[Arc<str>],
234 limit: usize,
235 pattern_text: &str,
236 match_type: MatchType,
237) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
238 const CHUNK: usize = 1024;
239
240 if paths.len() <= CHUNK {
243 let mut list = BestMatchesList::new(limit, pattern_text);
244 for &path_id in paths {
245 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
246 list.record_match(path_id, path, match_type);
247 }
248 }
249 return list.matches;
250 }
251
252 let heaps: Vec<_> = paths
253 .par_chunks(CHUNK)
254 .map_init(
255 || BestMatchesList::new(limit, pattern_text),
256 |list, chunk| {
257 for &path_id in chunk {
258 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
259 list.record_match(path_id, path, match_type);
260 }
261 }
262 std::mem::take(&mut list.matches)
263 },
264 )
265 .collect();
266
267 merge_top_k(heaps, limit)
268}
269
270fn merge_top_k(
276 heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>>,
277 limit: usize,
278) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
279 let mut merged = BinaryHeap::with_capacity(limit);
280 for heap in heaps {
281 for Reverse(item) in heap.into_vec() {
282 push_top_match(&mut merged, limit, item.0, item.1, item.2);
283 }
284 }
285 merged
286}
287
288pub struct FileIndexCache {
290 cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
291 snapshot: Arc<ArcSwapOption<FileIndex>>,
295 build_gate: Arc<tokio::sync::Semaphore>,
298 search_directory: std::path::PathBuf,
299 exclude: Vec<String>,
300 respect_gitignore: bool,
301 threads: usize,
302}
303
304impl FileIndexCache {
305 pub fn new(
306 search_directory: std::path::PathBuf,
307 exclude: impl IntoIterator<Item = String>,
308 respect_gitignore: bool,
309 threads: usize,
310 ) -> Self {
311 Self {
312 cache: Arc::new(RwLock::new(None)),
313 snapshot: Arc::new(ArcSwapOption::empty()),
314 build_gate: Arc::new(tokio::sync::Semaphore::new(1)),
315 search_directory,
316 exclude: exclude.into_iter().collect(),
317 respect_gitignore,
318 threads,
319 }
320 }
321
322 pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
324 {
326 let guard = self.cache.read().await;
327 if let Some(index) = guard.as_ref() {
328 if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
330 return Ok(Arc::clone(index));
331 }
332 }
333 }
334
335 let _build_permit = self.build_gate.acquire().await?;
338 {
339 let guard = self.cache.read().await;
340 if let Some(index) = guard.as_ref()
341 && index.last_built.elapsed() < std::time::Duration::from_secs(300)
342 {
343 return Ok(Arc::clone(index));
344 }
345 }
346
347 let search_directory = self.search_directory.clone();
351 let exclude = self.exclude.clone();
352 let respect_gitignore = self.respect_gitignore;
353 let threads = self.threads;
354 let index = Arc::new(
355 tokio::task::spawn_blocking(move || {
356 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
357 })
358 .await??,
359 );
360
361 {
363 let mut guard = self.cache.write().await;
364 *guard = Some(Arc::clone(&index));
365 self.snapshot.store(Some(Arc::clone(&index)));
366 }
367 Ok(index)
368 }
369
370 pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
376 let runtime = match tokio::runtime::Handle::try_current() {
377 Ok(runtime) => runtime,
378 Err(error) => {
379 tracing::debug!(%error, "cannot refresh file index without a Tokio runtime");
380 return self.snapshot.load_full();
381 }
382 };
383
384 let search_directory = self.search_directory.clone();
386 let exclude = self.exclude.clone();
387 let respect_gitignore = self.respect_gitignore;
388 let threads = self.threads;
389 let cache = self.cache.clone();
390 let snapshot = Arc::clone(&self.snapshot);
391 let build_gate = Arc::clone(&self.build_gate);
392
393 runtime.spawn(async move {
394 let _build_permit = match build_gate.acquire_owned().await {
395 Ok(permit) => permit,
396 Err(error) => {
397 tracing::error!(%error, "file index build gate closed");
398 return;
399 }
400 };
401
402 match tokio::task::spawn_blocking(move || {
403 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
404 })
405 .await
406 {
407 Ok(Ok(new_index)) => {
408 let new_index = Arc::new(new_index);
409 let mut guard = cache.write().await;
410 *guard = Some(Arc::clone(&new_index));
411 snapshot.store(Some(new_index));
412 }
413 Ok(Err(error)) => {
414 tracing::error!(%error, "failed to rebuild file index");
415 }
416 Err(error) => {
417 tracing::error!(%error, "file index rebuild task failed");
418 }
419 }
420 });
421
422 self.snapshot.load_full()
423 }
424
425 pub fn update_file(&self, path: &str, is_added: bool) {
428 let mut guard = self.cache.blocking_write();
429 let Some(existing) = guard.take() else { return };
430
431 let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
432 if is_added {
433 let path_id = intern_path(path, &mut index.interner, Arc::make_mut(&mut index.path_texts_by_id));
434 let is_directory = self.search_directory.join(path).is_dir();
435 if is_directory {
436 index.files.retain(|&existing| existing != path_id);
437 if !index.directories.contains(&path_id) {
438 index.directories.push(path_id);
439 }
440 } else {
441 index.directories.retain(|&existing| existing != path_id);
442 if !index.files.contains(&path_id) {
443 index.files.push(path_id);
444 }
445 }
446 } else {
447 let Some(path_id) = index.files.iter().chain(index.directories.iter()).copied().find(|&path_id| {
448 index
449 .path_texts_by_id
450 .get(path_id.as_u32() as usize)
451 .is_some_and(|value| value.as_ref() == path)
452 }) else {
453 let index = Arc::new(index);
454 *guard = Some(Arc::clone(&index));
455 self.snapshot.store(Some(index));
456 return;
457 };
458 index.files.retain(|&existing| existing != path_id);
459 index.directories.retain(|&existing| existing != path_id);
460 }
461 index.last_built = std::time::Instant::now();
462 let index = Arc::new(index);
463 *guard = Some(Arc::clone(&index));
464 self.snapshot.store(Some(index));
465 }
466
467 pub async fn index_age(&self) -> Option<std::time::Duration> {
469 let guard = self.cache.read().await;
470 guard.as_ref().map(|idx| idx.last_built.elapsed())
471 }
472}
473
474impl Clone for FileIndex {
476 fn clone(&self) -> Self {
477 Self {
478 files: self.files.clone(),
479 directories: self.directories.clone(),
480 path_texts_by_id: Arc::clone(&self.path_texts_by_id),
481 interner: self.interner.clone(),
482 last_built: self.last_built,
483 }
484 }
485}
486
487#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
495#[serde(rename_all = "lowercase")]
496pub enum MatchType {
497 File,
498 Directory,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct FileMatch {
503 pub score: u32,
504 pub path: String,
505 pub match_type: MatchType,
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub indices: Option<Vec<u32>>,
508}
509
510#[derive(Debug)]
512pub struct FileSearchResults {
513 pub matches: Vec<FileMatch>,
514 pub total_match_count: usize,
515}
516
517pub struct FileSearchConfig {
519 pub pattern_text: String,
520 pub limit: NonZero<usize>,
521 pub search_directory: std::path::PathBuf,
522 pub exclude: Vec<String>,
523 pub threads: NonZero<usize>,
524 pub cancel_flag: Arc<AtomicBool>,
525 pub compute_indices: bool,
526 pub respect_gitignore: bool,
527}
528
529pub use vtcode_commons::paths::file_name_from_path;
530
531struct BestMatchesList {
536 matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
537 limit: usize,
538 matcher: nucleo_matcher::Matcher,
539 haystack_buf: Vec<char>,
540 pattern: PatternStorage,
542}
543
544enum PatternStorage {
546 Ascii(Vec<u8>),
548 Unicode(Vec<char>),
550}
551
552impl BestMatchesList {
553 fn new(limit: usize, pattern_text: &str) -> Self {
554 let pattern = if pattern_text.is_ascii() {
558 PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
559 } else {
560 PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
561 };
562
563 Self {
564 matches: BinaryHeap::new(),
565 limit,
566 matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
567 haystack_buf: Vec::with_capacity(256),
568 pattern,
569 }
570 }
571
572 fn score_path(&mut self, path: &str) -> Option<u32> {
575 let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
576 let needle = match &self.pattern {
577 PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
578 PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
579 };
580 self.matcher.fuzzy_match(haystack, needle).map(|score| score as u32)
581 }
582
583 fn record_match(&mut self, path_id: StringId, path: &str, match_type: MatchType) -> bool {
585 let Some(score) = self.score_path(path) else {
586 return false;
587 };
588 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
589 true
590 }
591
592 fn record_scored_match(&mut self, path_id: StringId, score: u32, match_type: MatchType) {
593 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
594 }
595}
596
597fn push_top_match(
598 matches: &mut BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
599 limit: usize,
600 score: u32,
601 path: StringId,
602 match_type: MatchType,
603) -> bool {
604 let candidate = (score, path, match_type);
605 if matches.len() < limit {
606 matches.push(Reverse(candidate));
607 return true;
608 }
609
610 let Some(minimum) = matches.peek().map(|entry| &entry.0) else {
611 return false;
612 };
613
614 if &candidate <= minimum {
615 return false;
616 }
617
618 matches.pop();
619 matches.push(Reverse(candidate));
620 true
621}
622
623pub async fn run_with_index(
637 config: FileSearchConfig,
638 index_cache: &FileIndexCache,
639) -> anyhow::Result<FileSearchResults> {
640 let limit = config.limit.get();
641 let cancel_flag = &config.cancel_flag;
642 let compute_indices = config.compute_indices;
643
644 let index = index_cache.get_or_build().await?;
646
647 if cancel_flag.load(Ordering::Relaxed) {
649 return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
650 }
651
652 let index_for_results = index.clone();
655 let matched_paths = tokio::task::spawn_blocking({
656 let pattern_text = config.pattern_text.clone();
657 move || Ok::<_, anyhow::Error>(index.query(&pattern_text, limit, None))
658 })
659 .await??;
660
661 let total_match_count = matched_paths.len();
662
663 let matches = matched_paths
665 .into_iter()
666 .filter_map(|(score, path_id, match_type)| {
667 let path = index_for_results.path_texts_by_id.get(path_id.as_u32() as usize)?.to_string();
668 Some(FileMatch {
669 score,
670 path,
671 match_type,
672 indices: if compute_indices { Some(Vec::new()) } else { None },
673 })
674 })
675 .collect();
676
677 Ok(FileSearchResults { matches, total_match_count })
678}
679
680pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
690 run_with_policy(config, true, false)
691}
692
693pub fn run_bounded_no_follow(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
699 run_bounded_no_follow_with_visit(config, |_| {})
700}
701
702fn run_bounded_no_follow_with_visit(
703 config: FileSearchConfig,
704 mut visit: impl FnMut(&Path),
705) -> anyhow::Result<FileSearchResults> {
706 let limit = config.limit.get();
707 let search_directory = &config.search_directory;
708 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
709 vtcode_commons::walk::apply_defaults(&mut walk_builder);
710 walk_builder
711 .follow_links(false)
712 .require_git(false)
713 .sort_by_file_path(|left, right| left.cmp(right));
714
715 if !config.respect_gitignore {
716 walk_builder
717 .git_ignore(false)
718 .git_global(false)
719 .git_exclude(false)
720 .ignore(false)
721 .parents(false);
722 }
723
724 if !config.exclude.is_empty() {
725 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
726 for exclude_pattern in &config.exclude {
727 override_builder.add(&format!("!{exclude_pattern}"))?;
728 }
729 walk_builder.overrides(override_builder.build()?);
730 }
731
732 let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
733 let mut matches = BestMatchesList::new(limit, &config.pattern_text);
734 let mut matching_count = 0usize;
735 for result in walk_builder.build() {
736 if config.cancel_flag.load(Ordering::Relaxed) {
737 break;
738 }
739 let entry = match result {
740 Ok(entry) => entry,
741 Err(_) => continue,
742 };
743 visit(entry.path());
744 if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
745 continue;
746 }
747 let Some(relative_path) = entry
748 .path()
749 .strip_prefix(search_directory)
750 .ok()
751 .and_then(|path| path.to_str())
752 .filter(|path| !path.is_empty())
753 else {
754 continue;
755 };
756 let Some(score) = matches.score_path(relative_path) else {
757 continue;
758 };
759 let path_id = interner.lock().intern(relative_path);
760 matches.record_scored_match(path_id, score, MatchType::File);
761 matching_count += 1;
762 if matching_count >= limit {
763 break;
764 }
765 }
766
767 let interner_guard = interner.lock();
768 let matches = matches
769 .matches
770 .into_sorted_vec()
771 .into_iter()
772 .filter_map(|Reverse((score, path_id, match_type))| {
773 let path = interner_guard.get(path_id)?.to_string();
774 Some(FileMatch {
775 score,
776 path,
777 match_type,
778 indices: config.compute_indices.then(Vec::new),
779 })
780 })
781 .collect();
782
783 Ok(FileSearchResults {
784 matches,
785 total_match_count: matching_count + usize::from(matching_count >= limit),
788 })
789}
790
791fn run_with_policy(
792 config: FileSearchConfig,
793 follow_links: bool,
794 files_only: bool,
795) -> anyhow::Result<FileSearchResults> {
796 let limit = config.limit.get();
797 let search_directory = &config.search_directory;
798 let exclude = &config.exclude;
799 let threads = config.threads.get();
800 let cancel_flag = &config.cancel_flag;
801 let compute_indices = config.compute_indices;
802 let respect_gitignore = config.respect_gitignore;
803
804 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, follow_links)?;
805
806 let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
807
808 let best_matchers_per_worker: Vec<Arc<Mutex<BestMatchesList>>> = (0..threads)
811 .map(|_| Arc::new(Mutex::new(BestMatchesList::new(limit, &config.pattern_text))))
812 .collect();
813
814 let total_match_count = Arc::new(AtomicUsize::new(0));
815
816 let worker_counter = AtomicUsize::new(0);
819 let worker_count = best_matchers_per_worker.len();
820 walker.run(|| {
821 let worker_id = worker_counter.fetch_add(1, Ordering::Relaxed) % worker_count;
822 let best_list = best_matchers_per_worker[worker_id].clone();
823 let cancel_flag_clone = cancel_flag.clone();
824 let total_match_count_clone = total_match_count.clone();
825 let interner_clone = interner.clone();
826
827 Box::new(move |result| {
828 if cancel_flag_clone.load(Ordering::Relaxed) {
830 return ignore::WalkState::Quit;
831 }
832
833 let entry = match result {
834 Ok(e) => e,
835 Err(_) => return ignore::WalkState::Continue,
836 };
837
838 let relative_path = entry.path().strip_prefix(search_directory).ok().and_then(|p| p.to_str());
840
841 let path_to_match = match relative_path {
842 Some(p) if !p.is_empty() => p,
843 _ => return ignore::WalkState::Continue, };
845
846 let Some(file_type) = entry.file_type() else {
847 return ignore::WalkState::Continue;
848 };
849 let match_type = if file_type.is_dir() {
850 MatchType::Directory
851 } else {
852 MatchType::File
853 };
854
855 if files_only && match_type == MatchType::Directory {
856 return ignore::WalkState::Continue;
857 }
858
859 {
861 let mut list = best_list.lock();
862 let Some(score) = list.score_path(path_to_match) else {
863 return ignore::WalkState::Continue;
864 };
865 let path_id = interner_clone.lock().intern(path_to_match);
866 list.record_scored_match(path_id, score, match_type);
867 total_match_count_clone.fetch_add(1, Ordering::Relaxed);
868 }
869
870 ignore::WalkState::Continue
871 })
872 });
873
874 let worker_heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>> = best_matchers_per_worker
876 .into_iter()
877 .map(|arc| std::mem::take(&mut arc.lock().matches))
878 .collect();
879 let merged_matches = merge_top_k(worker_heaps, limit);
880
881 let interner_guard = interner.lock();
883 let matches = merged_matches
884 .into_sorted_vec()
885 .into_iter()
886 .filter_map(|Reverse((score, path_id, match_type))| {
887 let path = interner_guard.get(path_id)?.to_string();
888 Some(FileMatch {
889 score,
890 path,
891 match_type,
892 indices: if compute_indices { Some(Vec::new()) } else { None },
893 })
894 })
895 .collect();
896
897 Ok(FileSearchResults {
898 matches,
899 total_match_count: total_match_count.load(Ordering::Relaxed),
900 })
901}
902
903#[cfg(test)]
904mod tests {
905 use super::{
906 FileIndexCache, FileSearchConfig, MatchType, run_bounded_no_follow, run_bounded_no_follow_with_visit,
907 run_with_index,
908 };
909 use std::num::NonZero;
910 use std::sync::Arc;
911 use std::sync::atomic::AtomicBool;
912 use tempfile::TempDir;
913
914 #[tokio::test(flavor = "current_thread")]
915 async fn concurrent_index_builds_share_async_cache_entry() {
916 let workspace = TempDir::new().expect("workspace");
917 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
918
919 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), true, 1);
920 let (first, second) = tokio::join!(cache.get_or_build(), cache.get_or_build());
921 let first = first.expect("build file index");
922 let second = second.expect("reuse file index");
923
924 assert!(Arc::ptr_eq(&first, &second));
925 }
926
927 #[tokio::test(flavor = "current_thread")]
928 async fn background_refresh_is_safe_when_called_from_tokio() {
929 let workspace = TempDir::new().expect("workspace");
930 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
931
932 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
933 cache.get_or_build().await.expect("initial index");
934
935 assert!(cache.refresh_background().is_some());
936 }
937
938 #[test]
939 fn background_refresh_returns_snapshot_without_runtime() {
940 let workspace = TempDir::new().expect("workspace");
941 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
942
943 let runtime = tokio::runtime::Runtime::new().expect("Tokio runtime");
944 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
945 runtime.block_on(cache.get_or_build()).expect("initial index");
946 drop(runtime);
947
948 assert!(cache.refresh_background().is_some());
949 }
950
951 #[tokio::test(flavor = "current_thread")]
952 async fn incremental_directory_updates_use_the_cache_root() {
953 let workspace = TempDir::new().expect("workspace");
954 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
955 std::fs::create_dir(workspace.path().join("new_directory")).expect("fixture directory");
956
957 let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
958 cache.get_or_build().await.expect("initial index");
959
960 tokio::task::spawn_blocking({
961 let cache = Arc::clone(&cache);
962 move || cache.update_file("new_directory", true)
963 })
964 .await
965 .expect("incremental directory update task");
966
967 let index = cache.get_or_build().await.expect("updated index");
968 let matches = index.query("new_directory", 16, None);
969 assert!(matches.iter().any(|(_, _, match_type)| *match_type == MatchType::Directory));
970 }
971
972 fn indexed_search_config(
973 workspace: &std::path::Path,
974 pattern: &str,
975 cancel_flag: Arc<AtomicBool>,
976 ) -> FileSearchConfig {
977 FileSearchConfig {
978 pattern_text: pattern.to_string(),
979 limit: NonZero::new(16).expect("non-zero limit"),
980 search_directory: workspace.to_path_buf(),
981 exclude: Vec::new(),
982 threads: NonZero::new(1).expect("non-zero threads"),
983 cancel_flag,
984 compute_indices: false,
985 respect_gitignore: false,
986 }
987 }
988
989 fn result_signature(results: &super::FileSearchResults) -> Vec<(u32, String, MatchType)> {
990 results
991 .matches
992 .iter()
993 .map(|candidate| (candidate.score, candidate.path.clone(), candidate.match_type))
994 .collect()
995 }
996
997 #[tokio::test(flavor = "current_thread")]
998 async fn indexed_search_preserves_scores_order_and_match_types() {
999 let workspace = TempDir::new().expect("workspace");
1000 std::fs::create_dir_all(workspace.path().join("src/widget_dir")).expect("fixture directory");
1001 std::fs::write(workspace.path().join("src/widget.rs"), "fn widget() {}\n").expect("fixture source");
1002 std::fs::write(workspace.path().join("src/widget_test.rs"), "fn widget_test() {}\n").expect("fixture source");
1003 std::fs::write(workspace.path().join("README.md"), "widget documentation\n").expect("fixture docs");
1004
1005 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1006 let first =
1007 run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1008 .await
1009 .expect("indexed search");
1010 let second =
1011 run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1012 .await
1013 .expect("repeat indexed search");
1014
1015 assert_eq!(result_signature(&first), result_signature(&second));
1016 assert!(
1017 first
1018 .matches
1019 .iter()
1020 .any(|candidate| candidate.match_type == MatchType::Directory)
1021 );
1022 assert!(first.matches.iter().any(|candidate| candidate.match_type == MatchType::File));
1023 assert!(first.matches.windows(2).all(|window| window[0].score >= window[1].score));
1024 }
1025
1026 #[tokio::test(flavor = "current_thread")]
1027 async fn indexed_search_honors_cancellation_before_scoring() {
1028 let workspace = TempDir::new().expect("workspace");
1029 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1030 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1031 let cancel_flag = Arc::new(AtomicBool::new(true));
1032
1033 let results = run_with_index(indexed_search_config(workspace.path(), "widget", cancel_flag), &cache)
1034 .await
1035 .expect("cancelled indexed search");
1036 assert!(results.matches.is_empty());
1037 assert_eq!(results.total_match_count, 0);
1038 }
1039
1040 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1041 async fn incremental_updates_do_not_mutate_an_old_search_index() {
1042 let workspace = TempDir::new().expect("workspace");
1043 std::fs::write(workspace.path().join("old_widget.rs"), "fn old_widget() {}\n").expect("fixture source");
1044 let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
1045 let old_index = cache.get_or_build().await.expect("initial index");
1046
1047 tokio::task::spawn_blocking({
1048 let cache = Arc::clone(&cache);
1049 move || cache.update_file("new_widget.rs", true)
1050 })
1051 .await
1052 .expect("incremental update task");
1053
1054 let old_matches = old_index.query("widget", 16, None);
1055 let new_index = cache.get_or_build().await.expect("updated index");
1056 let new_matches = new_index.query("widget", 16, None);
1057 let old_paths = old_matches
1058 .iter()
1059 .filter_map(|(_, path_id, _)| old_index.path_texts_by_id.get(path_id.as_u32() as usize))
1060 .map(AsRef::as_ref)
1061 .collect::<Vec<&str>>();
1062 let new_paths = new_matches
1063 .iter()
1064 .filter_map(|(_, path_id, _)| new_index.path_texts_by_id.get(path_id.as_u32() as usize))
1065 .map(AsRef::as_ref)
1066 .collect::<Vec<&str>>();
1067
1068 assert!(!old_paths.contains(&"new_widget.rs"));
1069 assert!(new_paths.contains(&"new_widget.rs"));
1070 }
1071
1072 fn bounded_paths(workspace: &std::path::Path) -> Vec<String> {
1073 run_bounded_no_follow(FileSearchConfig {
1074 pattern_text: "widget".to_string(),
1075 limit: NonZero::new(2).expect("non-zero limit"),
1076 search_directory: workspace.to_path_buf(),
1077 exclude: Vec::new(),
1078 threads: NonZero::new(4).expect("non-zero threads"),
1079 cancel_flag: Arc::new(AtomicBool::new(false)),
1080 compute_indices: false,
1081 respect_gitignore: true,
1082 })
1083 .expect("bounded path search")
1084 .matches
1085 .into_iter()
1086 .map(|candidate| candidate.path)
1087 .collect()
1088 }
1089
1090 #[test]
1091 fn bounded_path_selection_is_stable_across_repeated_walks() {
1092 let workspace = TempDir::new().expect("workspace");
1093 for directory in ["z", "a", "m", "b", "y"] {
1094 let directory = workspace.path().join(directory);
1095 std::fs::create_dir(&directory).expect("fixture directory");
1096 std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1097 }
1098
1099 let expected = bounded_paths(workspace.path());
1100 assert_eq!(expected.len(), 2);
1101 for _ in 0..20 {
1102 assert_eq!(bounded_paths(workspace.path()), expected);
1103 }
1104 }
1105
1106 #[test]
1107 fn bounded_path_selection_is_the_sorted_prefix_and_stops_early() {
1108 let workspace = TempDir::new().expect("workspace");
1109 for directory in ["z", "a", "m", "b", "y"] {
1110 let directory = workspace.path().join(directory);
1111 std::fs::create_dir(&directory).expect("fixture directory");
1112 std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1113 }
1114 let mut visited = Vec::new();
1115
1116 let results = run_bounded_no_follow_with_visit(
1117 FileSearchConfig {
1118 pattern_text: "widget".to_string(),
1119 limit: NonZero::new(2).expect("non-zero limit"),
1120 search_directory: workspace.path().to_path_buf(),
1121 exclude: Vec::new(),
1122 threads: NonZero::new(4).expect("non-zero threads"),
1123 cancel_flag: Arc::new(AtomicBool::new(false)),
1124 compute_indices: false,
1125 respect_gitignore: true,
1126 },
1127 |path| visited.push(path.to_path_buf()),
1128 )
1129 .expect("bounded path search");
1130 let mut paths = results.matches.into_iter().map(|candidate| candidate.path).collect::<Vec<_>>();
1131 paths.sort();
1132
1133 assert_eq!(paths, vec!["a/widget.rs", "b/widget.rs"]);
1134 assert!(visited.len() < 11, "the bounded route must stop before traversing the complete fixture tree");
1135 assert_eq!(results.total_match_count, 3);
1136 }
1137}