1use crossbeam_channel::Receiver;
2use crossbeam_channel::Sender;
3use crossbeam_channel::after;
4use crossbeam_channel::never;
5use crossbeam_channel::select;
6use crossbeam_channel::unbounded;
7use ignore::WalkBuilder;
8use ignore::overrides::OverrideBuilder;
9use nucleo::Config;
10use nucleo::Injector;
11use nucleo::Matcher;
12use nucleo::Nucleo;
13use nucleo::Utf32String;
14use nucleo::pattern::CaseMatching;
15use nucleo::pattern::Normalization;
16use serde::Serialize;
17use std::num::NonZero;
18use std::path::Path;
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::sync::Condvar;
22use std::sync::Mutex;
23use std::sync::RwLock;
24use std::sync::atomic::AtomicBool;
25use std::sync::atomic::Ordering;
26use std::thread;
27use std::time::Duration;
28use tokio::process::Command;
29
30#[cfg(test)]
31use nucleo::Utf32Str;
32#[cfg(test)]
33use nucleo::pattern::AtomKind;
34#[cfg(test)]
35use nucleo::pattern::Pattern;
36
37mod cli;
38
39pub use cli::Cli;
40
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
54pub struct FileMatch {
55 pub score: u32,
56 pub path: PathBuf,
57 pub match_type: MatchType,
58 pub root: PathBuf,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub indices: Option<Vec<u32>>, }
62
63#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
64#[serde(rename_all = "lowercase")]
65pub enum MatchType {
66 File,
67 Directory,
68}
69
70impl FileMatch {
71 pub fn full_path(&self) -> PathBuf {
72 self.root.join(&self.path)
73 }
74}
75
76pub fn file_name_from_path(path: &str) -> String {
78 Path::new(path)
79 .file_name()
80 .map(|name| name.to_string_lossy().into_owned())
81 .unwrap_or_else(|| path.to_string())
82}
83
84#[derive(Debug)]
85pub struct FileSearchResults {
86 pub matches: Vec<FileMatch>,
87 pub total_match_count: usize,
88}
89
90#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
91pub struct FileSearchSnapshot {
92 pub query: String,
93 pub matches: Vec<FileMatch>,
94 pub total_match_count: usize,
95 pub scanned_file_count: usize,
96 pub walk_complete: bool,
97}
98
99#[derive(Debug, Clone)]
100pub struct FileSearchOptions {
101 pub limit: NonZero<usize>,
102 pub exclude: Vec<String>,
103 pub threads: NonZero<usize>,
104 pub compute_indices: bool,
105 pub respect_gitignore: bool,
113}
114
115impl Default for FileSearchOptions {
116 fn default() -> Self {
117 Self {
118 #[expect(clippy::unwrap_used)]
119 limit: NonZero::new(20).unwrap(),
120 exclude: Vec::new(),
121 #[expect(clippy::unwrap_used)]
122 threads: NonZero::new(2).unwrap(),
123 compute_indices: false,
124 respect_gitignore: true,
125 }
126 }
127}
128
129pub trait SessionReporter: Send + Sync + 'static {
130 fn on_update(&self, snapshot: &FileSearchSnapshot);
132
133 fn on_complete(&self);
135}
136
137pub struct FileSearchSession {
138 inner: Arc<SessionInner>,
139}
140
141impl FileSearchSession {
142 pub fn update_query(&self, pattern_text: &str) {
144 let _ = self
145 .inner
146 .work_tx
147 .send(WorkSignal::QueryUpdated(pattern_text.to_string()));
148 }
149}
150
151impl Drop for FileSearchSession {
152 fn drop(&mut self) {
153 self.inner.shutdown.store(true, Ordering::Relaxed);
154 let _ = self.inner.work_tx.send(WorkSignal::Shutdown);
155 }
156}
157
158pub fn create_session(
159 search_directories: Vec<PathBuf>,
160 options: FileSearchOptions,
161 reporter: Arc<dyn SessionReporter>,
162 cancel_flag: Option<Arc<AtomicBool>>,
163) -> anyhow::Result<FileSearchSession> {
164 let FileSearchOptions {
165 limit,
166 exclude,
167 threads,
168 compute_indices,
169 respect_gitignore,
170 } = options;
171
172 let Some(primary_search_directory) = search_directories.first() else {
173 anyhow::bail!("at least one search directory is required");
174 };
175 let override_matcher = build_override_matcher(primary_search_directory, &exclude)?;
176 let (work_tx, work_rx) = unbounded();
177
178 let notify_tx = work_tx.clone();
179 let notify = Arc::new(move || {
180 let _ = notify_tx.send(WorkSignal::NucleoNotify);
181 });
182 let nucleo = Nucleo::new(
183 Config::DEFAULT.match_paths(),
184 notify,
185 Some(threads.get()),
186 1,
187 );
188 let injector = nucleo.injector();
189
190 let cancelled = cancel_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
191
192 let inner = Arc::new(SessionInner {
193 search_directories,
194 limit: limit.get(),
195 threads: threads.get(),
196 compute_indices,
197 respect_gitignore,
198 cancelled,
199 shutdown: Arc::new(AtomicBool::new(false)),
200 reporter,
201 work_tx,
202 });
203
204 let matcher_inner = inner.clone();
205 thread::spawn(move || matcher_worker(matcher_inner, work_rx, nucleo));
206
207 let walker_inner = inner.clone();
208 thread::spawn(move || walker_worker(walker_inner, override_matcher, injector));
209
210 Ok(FileSearchSession { inner })
211}
212
213pub trait Reporter {
214 fn report_match(&self, file_match: &FileMatch);
215 fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize);
216 fn warn_no_search_pattern(&self, search_directory: &Path);
217}
218
219pub async fn run_main<T: Reporter>(
220 Cli {
221 pattern,
222 limit,
223 cwd,
224 compute_indices,
225 json: _,
226 exclude,
227 threads,
228 }: Cli,
229 reporter: T,
230) -> anyhow::Result<()> {
231 let search_directory = match cwd {
232 Some(dir) => dir,
233 None => std::env::current_dir()?,
234 };
235 let pattern_text = match pattern {
236 Some(pattern) => pattern,
237 None => {
238 reporter.warn_no_search_pattern(&search_directory);
239 #[cfg(unix)]
240 Command::new("ls")
241 .arg("-al")
242 .current_dir(search_directory)
243 .stdout(std::process::Stdio::inherit())
244 .stderr(std::process::Stdio::inherit())
245 .status()
246 .await?;
247 #[cfg(windows)]
248 {
249 Command::new("cmd")
250 .arg("/c")
251 .arg(search_directory)
252 .stdout(std::process::Stdio::inherit())
253 .stderr(std::process::Stdio::inherit())
254 .status()
255 .await?;
256 }
257 return Ok(());
258 }
259 };
260
261 let FileSearchResults {
262 total_match_count,
263 matches,
264 } = run(
265 &pattern_text,
266 vec![search_directory.to_path_buf()],
267 FileSearchOptions {
268 limit,
269 exclude,
270 threads,
271 compute_indices,
272 respect_gitignore: true,
273 },
274 None,
275 )?;
276 let match_count = matches.len();
277 let matches_truncated = total_match_count > match_count;
278
279 for file_match in matches {
280 reporter.report_match(&file_match);
281 }
282 if matches_truncated {
283 reporter.warn_matches_truncated(total_match_count, match_count);
284 }
285
286 Ok(())
287}
288
289pub fn run(
292 pattern_text: &str,
293 roots: Vec<PathBuf>,
294 options: FileSearchOptions,
295 cancel_flag: Option<Arc<AtomicBool>>,
296) -> anyhow::Result<FileSearchResults> {
297 let reporter = Arc::new(RunReporter::default());
298 let session = create_session(roots, options, reporter.clone(), cancel_flag)?;
299
300 session.update_query(pattern_text);
301
302 let snapshot = reporter.wait_for_complete();
303 Ok(FileSearchResults {
304 matches: snapshot.matches,
305 total_match_count: snapshot.total_match_count,
306 })
307}
308
309#[cfg(test)]
311fn sort_matches(matches: &mut [(u32, String)]) {
312 matches.sort_by(cmp_by_score_desc_then_path_asc::<(u32, String), _, _>(
313 |t| t.0,
314 |t| t.1.as_str(),
315 ));
316}
317
318pub fn cmp_by_score_desc_then_path_asc<T, FScore, FPath>(
321 score_of: FScore,
322 path_of: FPath,
323) -> impl FnMut(&T, &T) -> std::cmp::Ordering
324where
325 FScore: Fn(&T) -> u32,
326 FPath: Fn(&T) -> &str,
327{
328 use std::cmp::Ordering;
329 move |a, b| match score_of(b).cmp(&score_of(a)) {
330 Ordering::Equal => path_of(a).cmp(path_of(b)),
331 other => other,
332 }
333}
334
335#[cfg(test)]
336fn create_pattern(pattern: &str) -> Pattern {
337 Pattern::new(
338 pattern,
339 CaseMatching::Ignore,
340 Normalization::Smart,
341 AtomKind::Fuzzy,
342 )
343}
344
345struct SessionInner {
346 search_directories: Vec<PathBuf>,
347 limit: usize,
348 threads: usize,
349 compute_indices: bool,
350 respect_gitignore: bool,
351 cancelled: Arc<AtomicBool>,
352 shutdown: Arc<AtomicBool>,
353 reporter: Arc<dyn SessionReporter>,
354 work_tx: Sender<WorkSignal>,
355}
356
357enum WorkSignal {
358 QueryUpdated(String),
359 NucleoNotify,
360 WalkComplete,
361 Shutdown,
362}
363
364fn build_override_matcher(
365 search_directory: &Path,
366 exclude: &[String],
367) -> anyhow::Result<Option<ignore::overrides::Override>> {
368 if exclude.is_empty() {
369 return Ok(None);
370 }
371 let mut override_builder = OverrideBuilder::new(search_directory);
372 for exclude in exclude {
373 let exclude_pattern = format!("!{exclude}");
374 override_builder.add(&exclude_pattern)?;
375 }
376 let matcher = override_builder.build()?;
377 Ok(Some(matcher))
378}
379
380fn get_file_path<'a>(path: &'a Path, search_directories: &[PathBuf]) -> Option<(usize, &'a str)> {
381 let mut best_match: Option<(usize, &Path)> = None;
382 for (idx, root) in search_directories.iter().enumerate() {
383 if let Ok(rel_path) = path.strip_prefix(root) {
384 let root_depth = root.components().count();
385 match best_match {
386 Some((best_idx, _))
387 if search_directories[best_idx].components().count() >= root_depth => {}
388 _ => {
389 best_match = Some((idx, rel_path));
390 }
391 }
392 }
393 }
394
395 let (root_idx, rel_path) = best_match?;
396 rel_path.to_str().map(|p| (root_idx, p))
397}
398
399fn walker_worker(
412 inner: Arc<SessionInner>,
413 override_matcher: Option<ignore::overrides::Override>,
414 injector: Injector<Arc<str>>,
415) {
416 let Some(first_root) = inner.search_directories.first() else {
417 let _ = inner.work_tx.send(WorkSignal::WalkComplete);
418 return;
419 };
420
421 let mut walk_builder = WalkBuilder::new(first_root);
422 for root in inner.search_directories.iter().skip(1) {
423 walk_builder.add(root);
424 }
425 walk_builder
426 .threads(inner.threads)
427 .hidden(false)
429 .follow_links(true)
431 .require_git(true);
434 if !inner.respect_gitignore {
435 walk_builder
436 .git_ignore(false)
437 .git_global(false)
438 .git_exclude(false)
439 .ignore(false)
440 .parents(false);
441 }
442 if let Some(override_matcher) = override_matcher {
443 walk_builder.overrides(override_matcher);
444 }
445
446 let walker = walk_builder.build_parallel();
447
448 walker.run(|| {
449 const CHECK_INTERVAL: usize = 1024;
450 let mut n = 0;
451 let search_directories = inner.search_directories.clone();
452 let injector = injector.clone();
453 let cancelled = inner.cancelled.clone();
454 let shutdown = inner.shutdown.clone();
455
456 Box::new(move |entry| {
457 let entry = match entry {
458 Ok(entry) => entry,
459 Err(_) => return ignore::WalkState::Continue,
460 };
461 let path = entry.path();
462 let Some(full_path) = path.to_str() else {
463 return ignore::WalkState::Continue;
464 };
465 if let Some((_, relative_path)) = get_file_path(path, &search_directories) {
466 injector.push(Arc::from(full_path), |_, cols| {
467 cols[0] = Utf32String::from(relative_path);
468 });
469 }
470 n += 1;
471 if n >= CHECK_INTERVAL {
472 if cancelled.load(Ordering::Relaxed) || shutdown.load(Ordering::Relaxed) {
473 return ignore::WalkState::Quit;
474 }
475 n = 0;
476 }
477 ignore::WalkState::Continue
478 })
479 });
480 let _ = inner.work_tx.send(WorkSignal::WalkComplete);
481}
482
483fn matcher_worker(
484 inner: Arc<SessionInner>,
485 work_rx: Receiver<WorkSignal>,
486 mut nucleo: Nucleo<Arc<str>>,
487) -> anyhow::Result<()> {
488 const TICK_TIMEOUT_MS: u64 = 10;
489 let config = Config::DEFAULT.match_paths();
490 let mut indices_matcher = inner.compute_indices.then(|| Matcher::new(config.clone()));
491 let cancel_requested = || inner.cancelled.load(Ordering::Relaxed);
492 let shutdown_requested = || inner.shutdown.load(Ordering::Relaxed);
493
494 let mut last_query = String::new();
495 let mut next_notify = never();
496 let mut will_notify = false;
497 let mut walk_complete = false;
498
499 loop {
500 select! {
501 recv(work_rx) -> signal => {
502 let Ok(signal) = signal else {
503 break;
504 };
505 match signal {
506 WorkSignal::QueryUpdated(query) => {
507 let append = query.starts_with(&last_query);
508 nucleo.pattern.reparse(
509 0,
510 &query,
511 CaseMatching::Ignore,
512 Normalization::Smart,
513 append,
514 );
515 last_query = query;
516 will_notify = true;
517 next_notify = after(Duration::from_millis(0));
518 }
519 WorkSignal::NucleoNotify => {
520 if !will_notify {
521 will_notify = true;
522 next_notify = after(Duration::from_millis(TICK_TIMEOUT_MS));
523 }
524 }
525 WorkSignal::WalkComplete => {
526 walk_complete = true;
527 if !will_notify {
528 will_notify = true;
529 next_notify = after(Duration::from_millis(0));
530 }
531 }
532 WorkSignal::Shutdown => {
533 break;
534 }
535 }
536 }
537 recv(next_notify) -> _ => {
538 will_notify = false;
539 let status = nucleo.tick(TICK_TIMEOUT_MS);
540 if status.changed {
541 let snapshot = nucleo.snapshot();
542 let limit = inner.limit.min(snapshot.matched_item_count() as usize);
543 let pattern = snapshot.pattern().column_pattern(0);
544 let matches: Vec<_> = snapshot
545 .matches()
546 .iter()
547 .take(limit)
548 .filter_map(|match_| {
549 let item = snapshot.get_item(match_.idx)?;
550 let full_path = item.data.as_ref();
551 let (root_idx, relative_path) = get_file_path(Path::new(full_path), &inner.search_directories)?;
552 let indices = if let Some(indices_matcher) = indices_matcher.as_mut() {
553 let mut idx_vec = Vec::<u32>::new();
554 let haystack = item.matcher_columns[0].slice(..);
555 let _ = pattern.indices(haystack, indices_matcher, &mut idx_vec);
556 idx_vec.sort_unstable();
557 idx_vec.dedup();
558 Some(idx_vec)
559 } else {
560 None
561 };
562 let match_type = if Path::new(full_path).is_dir() {
563 MatchType::Directory
564 } else {
565 MatchType::File
566 };
567 Some(FileMatch {
568 score: match_.score,
569 path: PathBuf::from(relative_path),
570 match_type,
571 root: inner.search_directories[root_idx].clone(),
572 indices,
573 })
574 })
575 .collect();
576
577 let snapshot = FileSearchSnapshot {
578 query: last_query.clone(),
579 matches,
580 total_match_count: snapshot.matched_item_count() as usize,
581 scanned_file_count: snapshot.item_count() as usize,
582 walk_complete,
583 };
584 inner.reporter.on_update(&snapshot);
585 }
586 if !status.running && walk_complete {
587 inner.reporter.on_complete();
588 }
589 }
590 default(Duration::from_millis(100)) => {
591 }
593 }
594
595 if cancel_requested() || shutdown_requested() {
596 break;
597 }
598 }
599
600 inner.reporter.on_complete();
602
603 Ok(())
604}
605
606#[derive(Default)]
607struct RunReporter {
608 snapshot: RwLock<FileSearchSnapshot>,
609 completed: (Condvar, Mutex<bool>),
610}
611
612impl SessionReporter for RunReporter {
613 fn on_update(&self, snapshot: &FileSearchSnapshot) {
614 #[allow(clippy::unwrap_used)]
615 let mut guard = self.snapshot.write().unwrap();
616 *guard = snapshot.clone();
617 }
618
619 fn on_complete(&self) {
620 let (cv, mutex) = &self.completed;
621 #[allow(clippy::unwrap_used)]
622 let mut completed = mutex.lock().unwrap();
623 *completed = true;
624 cv.notify_all();
625 }
626}
627
628impl RunReporter {
629 fn wait_for_complete(&self) -> FileSearchSnapshot {
630 let (cv, mutex) = &self.completed;
631 #[allow(clippy::unwrap_used)]
632 let mut completed = mutex.lock().unwrap();
633 while !*completed {
634 #[allow(clippy::unwrap_used)]
635 {
636 completed = cv.wait(completed).unwrap();
637 }
638 }
639 #[allow(clippy::unwrap_used)]
640 self.snapshot.read().unwrap().clone()
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 #![allow(clippy::unwrap_used)]
647
648 use super::*;
649 use pretty_assertions::assert_eq;
650 use std::fs;
651 use std::sync::Arc;
652 use std::sync::Condvar;
653 use std::sync::Mutex;
654 use std::sync::atomic::AtomicBool;
655 use std::thread;
656 use std::time::Duration;
657 use std::time::Instant;
658 use tempfile::TempDir;
659
660 #[test]
661 fn verify_score_is_none_for_non_match() {
662 let mut utf32buf = Vec::<char>::new();
663 let line = "hello";
664 let mut matcher = Matcher::new(Config::DEFAULT);
665 let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf);
666 let pattern = create_pattern("zzz");
667 let score = pattern.score(haystack, &mut matcher);
668 assert_eq!(score, None);
669 }
670
671 #[test]
672 fn tie_breakers_sort_by_path_when_scores_equal() {
673 let mut matches = vec![
674 (100, "b_path".to_string()),
675 (100, "a_path".to_string()),
676 (90, "zzz".to_string()),
677 ];
678
679 sort_matches(&mut matches);
680
681 let expected = vec![
683 (100, "a_path".to_string()),
684 (100, "b_path".to_string()),
685 (90, "zzz".to_string()),
686 ];
687
688 assert_eq!(matches, expected);
689 }
690
691 #[test]
692 fn file_name_from_path_uses_basename() {
693 assert_eq!(file_name_from_path("foo/bar.txt"), "bar.txt");
694 }
695
696 #[test]
697 fn file_name_from_path_falls_back_to_full_path() {
698 assert_eq!(file_name_from_path(""), "");
699 }
700
701 #[derive(Default)]
702 struct RecordingReporter {
703 updates: Mutex<Vec<FileSearchSnapshot>>,
704 complete_times: Mutex<Vec<Instant>>,
705 complete_cv: Condvar,
706 update_cv: Condvar,
707 }
708
709 impl RecordingReporter {
710 fn wait_until<T, F>(
711 &self,
712 mutex: &Mutex<T>,
713 cv: &Condvar,
714 timeout: Duration,
715 mut predicate: F,
716 ) -> bool
717 where
718 F: FnMut(&T) -> bool,
719 {
720 let deadline = Instant::now() + timeout;
721 let mut state = mutex.lock().unwrap();
722 loop {
723 if predicate(&state) {
724 return true;
725 }
726 let remaining = deadline.saturating_duration_since(Instant::now());
727 if remaining.is_zero() {
728 return false;
729 }
730 let (next_state, wait_result) = cv.wait_timeout(state, remaining).unwrap();
731 state = next_state;
732 if wait_result.timed_out() {
733 return predicate(&state);
734 }
735 }
736 }
737
738 fn wait_for_complete(&self, timeout: Duration) -> bool {
739 self.wait_until(
740 &self.complete_times,
741 &self.complete_cv,
742 timeout,
743 |completes| !completes.is_empty(),
744 )
745 }
746 fn clear(&self) {
747 self.updates.lock().unwrap().clear();
748 self.complete_times.lock().unwrap().clear();
749 }
750
751 fn updates(&self) -> Vec<FileSearchSnapshot> {
752 self.updates.lock().unwrap().clone()
753 }
754
755 fn wait_for_updates_at_least(&self, min_len: usize, timeout: Duration) -> bool {
756 self.wait_until(&self.updates, &self.update_cv, timeout, |updates| {
757 updates.len() >= min_len
758 })
759 }
760
761 fn snapshot(&self) -> FileSearchSnapshot {
762 self.updates
763 .lock()
764 .unwrap()
765 .last()
766 .cloned()
767 .unwrap_or_default()
768 }
769 }
770
771 impl SessionReporter for RecordingReporter {
772 fn on_update(&self, snapshot: &FileSearchSnapshot) {
773 let mut updates = self.updates.lock().unwrap();
774 updates.push(snapshot.clone());
775 self.update_cv.notify_all();
776 }
777
778 fn on_complete(&self) {
779 {
780 let mut complete_times = self.complete_times.lock().unwrap();
781 complete_times.push(Instant::now());
782 }
783 self.complete_cv.notify_all();
784 }
785 }
786
787 fn create_temp_tree(file_count: usize) -> TempDir {
788 let dir = tempfile::tempdir().unwrap();
789 for i in 0..file_count {
790 let path = dir.path().join(format!("file-{i:04}.txt"));
791 fs::write(path, format!("contents {i}")).unwrap();
792 }
793 dir
794 }
795
796 #[test]
797 fn session_scanned_file_count_is_monotonic_across_queries() {
798 let dir = create_temp_tree(200);
799 let reporter = Arc::new(RecordingReporter::default());
800 let session = create_session(
801 vec![dir.path().to_path_buf()],
802 FileSearchOptions::default(),
803 reporter.clone(),
804 None,
805 )
806 .expect("session");
807
808 session.update_query("file-00");
809 thread::sleep(Duration::from_millis(20));
810 let first_snapshot = reporter.snapshot();
811 session.update_query("file-01");
812 thread::sleep(Duration::from_millis(20));
813 let second_snapshot = reporter.snapshot();
814 let _ = reporter.wait_for_complete(Duration::from_secs(5));
815 let completed_snapshot = reporter.snapshot();
816
817 assert!(second_snapshot.scanned_file_count >= first_snapshot.scanned_file_count);
818 assert!(completed_snapshot.scanned_file_count >= second_snapshot.scanned_file_count);
819 }
820
821 #[test]
822 fn session_streams_updates_before_walk_complete() {
823 let dir = create_temp_tree(600);
824 let reporter = Arc::new(RecordingReporter::default());
825 let session = create_session(
826 vec![dir.path().to_path_buf()],
827 FileSearchOptions::default(),
828 reporter.clone(),
829 None,
830 )
831 .expect("session");
832
833 session.update_query("file-0");
834 let completed = reporter.wait_for_complete(Duration::from_secs(5));
835
836 assert!(completed);
837 let updates = reporter.updates();
838 assert!(updates.iter().any(|snapshot| !snapshot.walk_complete));
839 }
840
841 #[test]
842 fn session_accepts_query_updates_after_walk_complete() {
843 let dir = tempfile::tempdir().unwrap();
844 fs::write(dir.path().join("alpha.txt"), "alpha").unwrap();
845 fs::write(dir.path().join("beta.txt"), "beta").unwrap();
846 let reporter = Arc::new(RecordingReporter::default());
847 let session = create_session(
848 vec![dir.path().to_path_buf()],
849 FileSearchOptions::default(),
850 reporter.clone(),
851 None,
852 )
853 .expect("session");
854
855 session.update_query("alpha");
856 assert!(reporter.wait_for_complete(Duration::from_secs(5)));
857 let updates_before = reporter.updates().len();
858
859 session.update_query("beta");
860 assert!(reporter.wait_for_updates_at_least(updates_before + 1, Duration::from_secs(5),));
861
862 let updates = reporter.updates();
863 let last_update = updates.last().cloned().expect("update");
864 assert!(
865 last_update
866 .matches
867 .iter()
868 .any(|file_match| file_match.path.to_string_lossy().contains("beta.txt"))
869 );
870 }
871
872 #[test]
873 fn session_emits_complete_when_query_changes_with_no_matches() {
874 let dir = tempfile::tempdir().unwrap();
875 fs::write(dir.path().join("alpha.txt"), "alpha").unwrap();
876 fs::write(dir.path().join("beta.txt"), "beta").unwrap();
877 let reporter = Arc::new(RecordingReporter::default());
878 let session = create_session(
879 vec![dir.path().to_path_buf()],
880 FileSearchOptions::default(),
881 reporter.clone(),
882 None,
883 )
884 .expect("session");
885
886 session.update_query("asdf");
887 assert!(reporter.wait_for_complete(Duration::from_secs(5)));
888
889 let completed_snapshot = reporter.snapshot();
890 assert_eq!(completed_snapshot.matches, Vec::new());
891 assert_eq!(completed_snapshot.total_match_count, 0);
892
893 reporter.clear();
894
895 session.update_query("asdfa");
896 assert!(reporter.wait_for_complete(Duration::from_secs(5)));
897 assert!(!reporter.updates().is_empty());
898 }
899
900 #[test]
901 fn dropping_session_does_not_cancel_siblings_with_shared_cancel_flag() {
902 let root_a = create_temp_tree(200);
903 let root_b = create_temp_tree(4_000);
904 let cancel_flag = Arc::new(AtomicBool::new(false));
905
906 let reporter_a = Arc::new(RecordingReporter::default());
907 let session_a = create_session(
908 vec![root_a.path().to_path_buf()],
909 FileSearchOptions::default(),
910 reporter_a,
911 Some(cancel_flag.clone()),
912 )
913 .expect("session_a");
914
915 let reporter_b = Arc::new(RecordingReporter::default());
916 let session_b = create_session(
917 vec![root_b.path().to_path_buf()],
918 FileSearchOptions::default(),
919 reporter_b.clone(),
920 Some(cancel_flag),
921 )
922 .expect("session_b");
923
924 session_a.update_query("file-0");
925 session_b.update_query("file-1");
926
927 thread::sleep(Duration::from_millis(5));
928 drop(session_a);
929
930 let completed = reporter_b.wait_for_complete(Duration::from_secs(5));
931 assert_eq!(completed, true);
932 }
933
934 #[test]
935 fn session_emits_updates_when_query_changes() {
936 let dir = create_temp_tree(200);
937 let reporter = Arc::new(RecordingReporter::default());
938 let session = create_session(
939 vec![dir.path().to_path_buf()],
940 FileSearchOptions::default(),
941 reporter.clone(),
942 None,
943 )
944 .expect("session");
945
946 session.update_query("zzzzzzzz");
947 let completed = reporter.wait_for_complete(Duration::from_secs(5));
948 assert!(completed);
949
950 reporter.clear();
951
952 session.update_query("zzzzzzzzq");
953 let completed = reporter.wait_for_complete(Duration::from_secs(5));
954 assert!(completed);
955
956 let updates = reporter.updates();
957 assert_eq!(updates.len(), 1);
958 }
959
960 #[test]
961 fn run_returns_matches_for_query() {
962 let dir = create_temp_tree(40);
963 let options = FileSearchOptions {
964 limit: NonZero::new(20).unwrap(),
965 exclude: Vec::new(),
966 threads: NonZero::new(2).unwrap(),
967 compute_indices: false,
968 respect_gitignore: true,
969 };
970 let results = run(
971 "file-000",
972 vec![dir.path().to_path_buf()],
973 options,
974 None,
975 )
976 .expect("run ok");
977
978 assert!(!results.matches.is_empty());
979 assert!(results.total_match_count >= results.matches.len());
980 assert!(
981 results
982 .matches
983 .iter()
984 .any(|m| m.path.to_string_lossy().contains("file-0000.txt"))
985 );
986 }
987
988 #[test]
989 fn run_returns_directory_matches_for_query() {
990 let dir = tempfile::tempdir().unwrap();
991 fs::create_dir_all(dir.path().join("docs/guides")).unwrap();
992 fs::write(dir.path().join("docs/guides/intro.md"), "intro").unwrap();
993 fs::write(dir.path().join("docs/readme.md"), "readme").unwrap();
994
995 let results = run(
996 "guides",
997 vec![dir.path().to_path_buf()],
998 FileSearchOptions {
999 limit: NonZero::new(20).unwrap(),
1000 exclude: Vec::new(),
1001 threads: NonZero::new(2).unwrap(),
1002 compute_indices: false,
1003 respect_gitignore: true,
1004 },
1005 None,
1006 )
1007 .expect("run ok");
1008
1009 assert!(results.matches.iter().any(|m| {
1010 m.path == std::path::Path::new("docs").join("guides")
1011 && m.match_type == MatchType::Directory
1012 }));
1013 }
1014
1015 #[test]
1016 fn cancel_exits_run() {
1017 let dir = create_temp_tree(200);
1018 let cancel_flag = Arc::new(AtomicBool::new(true));
1019 let search_dir = dir.path().to_path_buf();
1020 let options = FileSearchOptions {
1021 compute_indices: false,
1022 ..Default::default()
1023 };
1024 let (tx, rx) = std::sync::mpsc::channel();
1025
1026 let handle = thread::spawn(move || {
1027 let result = run("file-", vec![search_dir], options, Some(cancel_flag));
1028 let _ = tx.send(result);
1029 });
1030
1031 let result = rx
1032 .recv_timeout(Duration::from_secs(2))
1033 .expect("run should exit after cancellation");
1034 handle.join().unwrap();
1035
1036 let results = result.expect("run ok");
1037 assert_eq!(results.matches, Vec::new());
1038 assert_eq!(results.total_match_count, 0);
1039 }
1040
1041 #[test]
1048 fn parent_gitignore_outside_repo_does_not_hide_repo_files() {
1049 let temp = tempfile::tempdir().unwrap();
1050 let parent = temp.path().join("home");
1051 let repo = parent.join("repo");
1052 fs::create_dir_all(repo.join(".vscode")).unwrap();
1053
1054 fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap();
1055 fs::write(
1056 repo.join(".gitignore"),
1057 ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n",
1058 )
1059 .unwrap();
1060 fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap();
1061 fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap();
1062
1063 let respect_results = run(
1064 "package",
1065 vec![repo.clone()],
1066 FileSearchOptions {
1067 limit: NonZero::new(20).unwrap(),
1068 exclude: Vec::new(),
1069 threads: NonZero::new(2).unwrap(),
1070 compute_indices: false,
1071 respect_gitignore: true,
1072 },
1073 None,
1074 )
1075 .expect("run ok");
1076 assert!(
1077 respect_results
1078 .matches
1079 .iter()
1080 .any(|m| m.path.as_path() == Path::new("package.json"))
1081 );
1082
1083 let nested_file_results = run(
1084 "settings",
1085 vec![repo],
1086 FileSearchOptions {
1087 limit: NonZero::new(20).unwrap(),
1088 exclude: Vec::new(),
1089 threads: NonZero::new(2).unwrap(),
1090 compute_indices: false,
1091 respect_gitignore: true,
1092 },
1093 None,
1094 )
1095 .expect("run ok");
1096 assert!(
1097 nested_file_results
1098 .matches
1099 .iter()
1100 .any(|m| m.path.as_path() == Path::new(".vscode/settings.json"))
1101 );
1102 }
1103
1104 #[test]
1105 fn git_repo_still_respects_local_gitignore_when_enabled() {
1106 let temp = tempfile::tempdir().unwrap();
1107 let parent = temp.path().join("home");
1108 let repo = parent.join("repo");
1109 fs::create_dir_all(repo.join(".vscode")).unwrap();
1110
1111 fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap();
1112 fs::write(
1113 repo.join(".gitignore"),
1114 ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n",
1115 )
1116 .unwrap();
1117 fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap();
1118 fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap();
1119 fs::write(
1120 repo.join(".vscode/extensions.json"),
1121 "{ \"extensions\": [] }\n",
1122 )
1123 .unwrap();
1124
1125 fs::create_dir_all(repo.join(".git")).unwrap();
1126
1127 let package_results = run(
1128 "package",
1129 vec![repo.clone()],
1130 FileSearchOptions {
1131 limit: NonZero::new(20).unwrap(),
1132 exclude: Vec::new(),
1133 threads: NonZero::new(2).unwrap(),
1134 compute_indices: false,
1135 respect_gitignore: true,
1136 },
1137 None,
1138 )
1139 .expect("run ok");
1140 assert!(
1141 package_results
1142 .matches
1143 .iter()
1144 .any(|m| m.path.as_path() == Path::new("package.json"))
1145 );
1146
1147 let ignored_results = run(
1148 "extensions.json",
1149 vec![repo.clone()],
1150 FileSearchOptions {
1151 limit: NonZero::new(20).unwrap(),
1152 exclude: Vec::new(),
1153 threads: NonZero::new(2).unwrap(),
1154 compute_indices: false,
1155 respect_gitignore: true,
1156 },
1157 None,
1158 )
1159 .expect("run ok");
1160 assert!(
1161 !ignored_results
1162 .matches
1163 .iter()
1164 .any(|m| m.path.as_path() == Path::new(".vscode/extensions.json"))
1165 );
1166
1167 let whitelisted_results = run(
1168 "settings.json",
1169 vec![repo],
1170 FileSearchOptions {
1171 limit: NonZero::new(20).unwrap(),
1172 exclude: Vec::new(),
1173 threads: NonZero::new(2).unwrap(),
1174 compute_indices: false,
1175 respect_gitignore: true,
1176 },
1177 None,
1178 )
1179 .expect("run ok");
1180 assert!(
1181 whitelisted_results
1182 .matches
1183 .iter()
1184 .any(|m| m.path.as_path() == Path::new(".vscode/settings.json"))
1185 );
1186 }
1187}