1use std::path::{Path, PathBuf};
21use std::result::Result;
22
23use regex::Regex;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MatchMode {
28 Literal,
30 Regex,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CaseSensitivity {
37 Sensitive,
39 Insensitive,
41 Smart,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LineMatch {
49 pub path: PathBuf,
51 pub line: usize,
53 pub column: usize,
56 pub line_start: usize,
61 pub byte_start: usize,
63 pub byte_end: usize,
65 pub line_text: String,
67 pub matched_text: String,
69 pub word_bounded: bool,
75}
76
77#[derive(Debug, Clone)]
79pub struct SearchRequest {
80 pub pattern: String,
82 pub root: PathBuf,
84 pub mode: MatchMode,
86 pub case: CaseSensitivity,
88 pub include_globs: Vec<String>,
92 pub language_globs: Vec<String>,
97 pub exclude_globs: Vec<String>,
99 pub max_results: usize,
101 pub max_per_file: usize,
103 pub max_file_size: Option<u64>,
105 pub include_hidden: bool,
107 pub respect_ignore_files: bool,
109 pub files_with_matches: bool,
111}
112
113impl SearchRequest {
114 #[must_use]
116 pub fn literal(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
117 Self {
118 pattern: pattern.into(),
119 root: root.into(),
120 mode: MatchMode::Literal,
121 case: CaseSensitivity::Smart,
122 include_globs: Vec::new(),
123 language_globs: Vec::new(),
124 exclude_globs: Vec::new(),
125 max_results: 100,
126 max_per_file: usize::MAX,
127 max_file_size: None,
128 include_hidden: false,
129 respect_ignore_files: true,
130 files_with_matches: false,
131 }
132 }
133
134 #[must_use]
136 pub fn regex(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
137 Self {
138 mode: MatchMode::Regex,
139 ..Self::literal(pattern, root)
140 }
141 }
142
143 #[must_use]
145 pub fn with_case(mut self, case: CaseSensitivity) -> Self {
146 self.case = case;
147 self
148 }
149
150 #[must_use]
152 pub fn with_max_results(mut self, max_results: usize) -> Self {
153 self.max_results = max_results;
154 self
155 }
156
157 #[must_use]
159 pub fn with_max_per_file(mut self, max_per_file: usize) -> Self {
160 self.max_per_file = max_per_file;
161 self
162 }
163
164 #[must_use]
168 pub fn with_include_glob(mut self, glob: impl Into<String>) -> Self {
169 self.include_globs.push(glob.into());
170 self
171 }
172
173 #[must_use]
178 pub fn with_language_glob(mut self, glob: impl Into<String>) -> Self {
179 self.language_globs.push(glob.into());
180 self
181 }
182
183 #[must_use]
185 pub fn with_hidden(mut self) -> Self {
186 self.include_hidden = true;
187 self
188 }
189
190 #[must_use]
192 pub fn with_max_file_size(mut self, max_file_size: u64) -> Self {
193 self.max_file_size = Some(max_file_size);
194 self
195 }
196
197 #[must_use]
199 pub fn with_files_with_matches(mut self) -> Self {
200 self.files_with_matches = true;
201 self
202 }
203
204 #[must_use]
209 pub fn with_exclude_glob(mut self, glob: impl Into<String>) -> Self {
210 self.exclude_globs.push(glob.into());
211 self
212 }
213
214 #[must_use]
216 pub fn without_ignore_files(mut self) -> Self {
217 self.respect_ignore_files = false;
218 self
219 }
220
221 pub fn compile(&self) -> Result<Regex, regex::Error> {
226 let pattern = match self.mode {
227 MatchMode::Literal => regex::escape(&self.pattern),
228 MatchMode::Regex => self.pattern.clone(),
229 };
230 let pattern = match self.case {
231 CaseSensitivity::Sensitive => pattern,
232 CaseSensitivity::Insensitive => format!("(?i){pattern}"),
233 CaseSensitivity::Smart => {
236 if self.pattern.chars().any(char::is_uppercase) {
237 pattern
238 } else {
239 format!("(?i){pattern}")
240 }
241 }
242 };
243 Regex::new(&pattern)
244 }
245}
246
247#[derive(Debug)]
249pub enum SearchError {
250 InvalidPattern(regex::Error),
252}
253
254impl std::fmt::Display for SearchError {
255 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::InvalidPattern(error) => write!(formatter, "invalid search pattern: {error}"),
258 }
259 }
260}
261
262impl std::error::Error for SearchError {}
263
264impl From<regex::Error> for SearchError {
265 fn from(error: regex::Error) -> Self {
266 Self::InvalidPattern(error)
267 }
268}
269
270pub fn search(request: &SearchRequest) -> Result<Vec<LineMatch>, SearchError> {
275 let matcher = request.compile()?;
276 let mut matches = Vec::new();
277
278 let mut candidates = candidate_files(request);
282 candidates.sort();
283
284 for path in candidates {
285 let remaining = request.max_results.saturating_sub(matches.len());
288 if remaining == 0 {
289 break;
290 }
291 let Ok(content) = std::fs::read_to_string(&path) else {
292 continue;
293 };
294 let per_file_cap = if request.files_with_matches {
297 1
298 } else {
299 request.max_per_file.min(remaining)
300 };
301 let file_matches = scan_file(&path, &content, &matcher, per_file_cap);
302 if file_matches.is_empty() {
303 continue;
304 }
305 if request.files_with_matches {
306 matches.extend(file_matches.into_iter().next());
310 } else {
311 matches.extend(file_matches);
312 }
313 }
314
315 matches.sort_by(|left, right| {
318 left.path
319 .cmp(&right.path)
320 .then_with(|| left.line.cmp(&right.line))
321 .then_with(|| left.column.cmp(&right.column))
322 });
323 Ok(matches)
324}
325
326fn candidate_files(request: &SearchRequest) -> Vec<PathBuf> {
328 if request.root.is_file() {
331 return vec![request.root.clone()];
332 }
333 let mut builder = crate::walk::build_default_walker(&request.root);
334 let _ = builder.hidden(!request.include_hidden);
335 let _ = builder.filter_entry(|entry| !crate::walk::is_excluded_dir(entry));
336 if !request.respect_ignore_files {
337 let _ = builder.git_ignore(false);
338 let _ = builder.git_global(false);
339 let _ = builder.git_exclude(false);
340 let _ = builder.ignore(false);
341 let _ = builder.parents(false);
342 }
343
344 let language_globs = lowercase_globs(&request.language_globs);
347
348 builder
349 .build()
350 .filter_map(Result::ok)
351 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
352 .map(|entry| entry.into_path())
353 .filter(|path| {
354 let relative = path.strip_prefix(&request.root).unwrap_or(path);
358 let relative = if relative.as_os_str().is_empty() {
359 path
360 } else {
361 relative
362 };
363 path_matches_include_globs(relative, &request.include_globs)
364 && path_matches_language_globs(relative, &language_globs)
365 && !path_matches_exclude_globs(relative, &request.exclude_globs)
366 })
367 .filter(|path| {
368 request
369 .max_file_size
370 .is_none_or(|limit| std::fs::metadata(path).map(|meta| meta.len() <= limit).unwrap_or(false))
371 })
372 .collect()
373}
374
375fn path_matches_include_globs(relative_path: &Path, globs: &[String]) -> bool {
381 if globs.is_empty() {
382 return true;
383 }
384 globs.iter().any(|glob| glob_matches_path(relative_path, glob))
385}
386
387fn lowercase_globs(globs: &[String]) -> Vec<String> {
389 globs.iter().map(|glob| glob.to_ascii_lowercase()).collect()
390}
391
392fn path_matches_language_globs(relative_path: &Path, globs: &[String]) -> bool {
398 if globs.is_empty() {
399 return true;
400 }
401 let Some(lowered) = lowercase_relative_path(relative_path) else {
402 return false;
403 };
404 let lowered = Path::new(&lowered);
405 globs.iter().any(|glob| {
406 glob_matches_path(lowered, glob)
407 || lowered.file_name().is_some_and(|name| glob_matches_path(Path::new(name), glob))
410 })
411}
412
413fn lowercase_relative_path(relative_path: &Path) -> Option<String> {
418 let mut lowered = String::new();
419 for component in relative_path.components() {
420 let std::path::Component::Normal(name) = component else {
421 return None;
422 };
423 if !lowered.is_empty() {
424 lowered.push('/');
425 }
426 lowered.push_str(&name.to_string_lossy().to_ascii_lowercase());
427 }
428 Some(lowered)
429}
430fn path_matches_exclude_globs(relative_path: &Path, globs: &[String]) -> bool {
435 globs.iter().any(|glob| glob_matches_path(relative_path, glob))
436}
437
438fn is_identifier_char(character: char) -> bool {
440 character.is_alphanumeric() || character == '_'
441}
442
443fn glob_matches_path(relative_path: &Path, glob: &str) -> bool {
449 let glob = glob.trim_start_matches('/');
450 let pattern_components: Vec<&str> = glob.split('/').collect();
451 let path_components: Vec<String> = relative_path
452 .components()
453 .filter_map(|component| match component {
454 std::path::Component::Normal(name) => name.to_str().map(str::to_owned),
455 _ => None,
456 })
457 .collect();
458
459 if !glob.contains('/') {
460 let Some(name) = path_components.last() else {
461 return false;
462 };
463 return match_components(&pattern_components, &[name.as_str()]);
464 }
465
466 let path_components: Vec<&str> = path_components.iter().map(String::as_str).collect();
467 match_components(&pattern_components, &path_components)
468}
469
470fn match_components(pattern: &[&str], path: &[&str]) -> bool {
472 match pattern.split_first() {
473 None => path.is_empty(),
474 Some((&"**", rest)) => {
475 (0..=path.len()).any(|skip| match_components(rest, path.get(skip..).unwrap_or_default()))
477 }
478 Some((&head, rest)) => match path.split_first() {
479 Some((&first, tail)) if component_matches(head, first) => match_components(rest, tail),
480 _ => false,
481 },
482 }
483}
484
485fn component_matches(pattern: &str, component: &str) -> bool {
487 let pattern: Vec<char> = pattern.chars().collect();
488 let component: Vec<char> = component.chars().collect();
489 component_glob_matches(&pattern, &component)
490}
491
492fn component_glob_matches(pattern: &[char], component: &[char]) -> bool {
494 match pattern.split_first() {
495 None => component.is_empty(),
496 Some((&'*', rest)) => {
497 (0..=component.len()).any(|skip| component_glob_matches(rest, component.get(skip..).unwrap_or_default()))
499 }
500 Some((&'?', rest)) => match component.split_first() {
501 Some((_, tail)) => component_glob_matches(rest, tail),
502 None => false,
503 },
504 Some((&head, rest)) => match component.split_first() {
505 Some((&first, tail)) if head == first => component_glob_matches(rest, tail),
506 _ => false,
507 },
508 }
509}
510
511fn scan_file(path: &Path, content: &str, matcher: &Regex, max_per_file: usize) -> Vec<LineMatch> {
513 let mut matches = Vec::new();
514 let mut line_start = 0usize;
517
518 for (index, line) in content.split_inclusive('\n').enumerate() {
519 let line_without_newline = line.trim_end_matches(['\n', '\r']);
520 for found in matcher.find_iter(line_without_newline) {
521 let matched_text = found.as_str();
522 let column = found.start() + 1;
525 let before = line_without_newline
526 .get(..found.start())
527 .and_then(|text| text.chars().next_back());
528 let after = line_without_newline.get(found.end()..).and_then(|text| text.chars().next());
529 let word_bounded = !before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char);
530 matches.push(LineMatch {
531 path: path.to_path_buf(),
532 line: index + 1,
533 column,
534 line_start,
535 byte_start: line_start + found.start(),
536 byte_end: line_start + found.end(),
537 line_text: line_without_newline.to_string(),
538 matched_text: matched_text.to_string(),
539 word_bounded,
540 });
541 if matches.len() >= max_per_file {
542 return matches;
543 }
544 }
545 line_start += line.len();
546 }
547
548 matches
549}
550
551#[cfg(test)]
552mod tests {
553 use super::{CaseSensitivity, MatchMode, SearchRequest, glob_matches_path, search};
554
555 use std::fs;
556 use std::path::Path;
557
558 use tempfile::tempdir;
559
560 fn fixture() -> tempfile::TempDir {
561 let dir = tempdir().expect("tempdir");
562 fs::write(dir.path().join("a.rs"), "fn main() {}\nlet needle = 1;\n").expect("write a.rs");
563 fs::write(dir.path().join("b.txt"), "needle in a text file\n").expect("write b.txt");
564 dir
565 }
566
567 #[test]
568 fn literal_mode_matches_metacharacters_verbatim() {
569 let dir = fixture();
570 let request = SearchRequest::literal("needle =", dir.path().to_path_buf());
571 let matches = search(&request).expect("search runs");
572
573 assert_eq!(matches.len(), 1, "only the literal occurrence matches");
574 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
575 assert_eq!(matches[0].line, 2);
576 assert_eq!(matches[0].matched_text, "needle =");
577 }
578
579 #[test]
580 fn regex_mode_treats_pattern_as_a_regular_expression() {
581 let dir = fixture();
582 let request = SearchRequest::regex(r"needle\s+in", dir.path().to_path_buf());
583 let matches = search(&request).expect("search runs");
584
585 assert_eq!(matches.len(), 1);
586 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("b.txt"));
587 }
588
589 #[test]
590 fn smart_case_relaxes_lowercase_patterns_only() {
591 let dir = tempdir().expect("tempdir");
592 fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
593
594 let lower = SearchRequest::literal("needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
595 assert_eq!(search(&lower).expect("search runs").len(), 2, "lower-case pattern matches both cases");
596
597 let upper = SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
598 assert_eq!(search(&upper).expect("search runs").len(), 1, "upper-case pattern stays case-sensitive");
599 }
600
601 #[test]
602 fn explicit_case_sensitivity_overrides_smart_case() {
603 let dir = tempdir().expect("tempdir");
604 fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
605
606 let request =
607 SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Insensitive);
608 assert_eq!(search(&request).expect("search runs").len(), 2);
609 }
610
611 #[test]
612 fn language_globs_match_case_insensitively_in_both_directions() {
613 let dir = tempdir().expect("tempdir");
616 fs::write(dir.path().join("main.rs"), "needle\n").expect("write lower-case file");
617 fs::write(dir.path().join("OTHER.RS"), "needle\n").expect("write upper-case file");
618
619 let upper_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.RS");
620 assert_eq!(search(&upper_glob).expect("search runs").len(), 2, "an upper-case glob matches both files");
621
622 let lower_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.rs");
623 assert_eq!(search(&lower_glob).expect("search runs").len(), 2, "a lower-case glob matches both files");
624 }
625
626 #[test]
627 fn include_globs_restrict_the_scanned_files() {
628 let dir = fixture();
629 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_include_glob("*.rs");
630 let matches = search(&request).expect("search runs");
631
632 assert_eq!(matches.len(), 1);
633 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
634 }
635
636 #[test]
637 fn max_results_stops_the_search_early() {
638 let dir = tempdir().expect("tempdir");
639 fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
640
641 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_results(2);
642 let matches = search(&request).expect("search runs");
643
644 assert_eq!(matches.len(), 2);
645 }
646
647 #[test]
648 fn max_per_file_caps_matches_within_one_file() {
649 let dir = tempdir().expect("tempdir");
650 fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
651
652 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_per_file(1);
653 let matches = search(&request).expect("search runs");
654
655 assert_eq!(matches.len(), 1);
656 }
657
658 #[test]
659 fn files_with_matches_reports_each_file_once() {
660 let dir = fixture();
661 let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
662 request.files_with_matches = true;
663 let matches = search(&request).expect("search runs");
664
665 assert_eq!(matches.len(), 2, "one entry per matching file");
666 }
667
668 #[test]
669 fn files_with_matches_honours_the_result_cap() {
670 let dir = tempdir().expect("tempdir");
671 for index in 0..4 {
672 fs::write(dir.path().join(format!("file{index}.txt")), "needle\n").expect("write");
673 }
674
675 let request = SearchRequest::literal("needle", dir.path().to_path_buf())
676 .with_max_results(2)
677 .with_files_with_matches();
678 let matches = search(&request).expect("search runs");
679
680 assert_eq!(matches.len(), 2, "the cap bounds the number of reported files");
681 }
682
683 #[test]
684 fn files_with_matches_reports_a_real_position_rather_than_a_placeholder() {
685 let dir = tempdir().expect("tempdir");
686 fs::write(dir.path().join("a.txt"), "first\nsecond needle\n").expect("write");
689
690 let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
691 request.files_with_matches = true;
692 let matches = search(&request).expect("search runs");
693
694 assert_eq!(matches.len(), 1);
695 let hit = &matches[0];
696 assert_eq!(hit.line, 2, "the record points at the line that matched");
697 assert_eq!(hit.column, 8, "and at the byte column of the match");
698 assert_eq!(hit.line_text, "second needle");
699 assert_eq!(hit.matched_text, "needle");
700 assert_eq!(
701 "first\nsecond needle\n".get(hit.byte_start..hit.byte_end),
702 Some("needle"),
703 "the offsets still index into the file"
704 );
705 }
706
707 #[test]
708 fn files_with_matches_reports_each_file_once_when_a_file_has_several_hits() {
709 let dir = tempdir().expect("tempdir");
710 fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
711 fs::write(dir.path().join("one.txt"), "needle\n").expect("write");
712
713 let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
714 request.files_with_matches = true;
715 let matches = search(&request).expect("search runs");
716
717 assert_eq!(matches.len(), 2, "one entry per matching file, not per hit");
718 }
719
720 #[test]
721 fn word_bounded_marks_exact_identifier_occurrences() {
722 let dir = tempdir().expect("tempdir");
723 fs::write(dir.path().join("words.txt"), "Widget WidgetExtra\n").expect("write");
724
725 let request = SearchRequest::literal("Widget", dir.path().to_path_buf());
726 let matches = search(&request).expect("search runs");
727
728 assert_eq!(matches.len(), 2);
729 assert!(matches[0].word_bounded, "the standalone occurrence is word-bounded");
730 assert!(!matches[1].word_bounded, "the occurrence inside a longer word is not");
731 }
732
733 #[test]
734 fn byte_offsets_are_relative_to_the_file() {
735 let dir = tempdir().expect("tempdir");
736 fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
737
738 let request = SearchRequest::literal("needle", dir.path().to_path_buf());
739 let matches = search(&request).expect("search runs");
740
741 assert_eq!(matches.len(), 1);
742 assert_eq!(matches[0].line, 2);
743 assert_eq!(
744 "first\nsecond needle\n"
745 .get(matches[0].byte_start..matches[0].byte_end)
746 .expect("the offsets index into the file"),
747 "needle",
748 "the offsets must index into the file, not the line"
749 );
750 }
751
752 #[test]
753 fn column_is_counted_in_bytes() {
754 let dir = tempdir().expect("tempdir");
755 fs::write(dir.path().join("unicode.txt"), "éé needle\n").expect("write");
758
759 let request = SearchRequest::literal("needle", dir.path().to_path_buf());
760 let matches = search(&request).expect("search runs");
761
762 assert_eq!(matches[0].column, 6, "column counts bytes, not characters");
763 assert_eq!(
764 matches[0].line_text.as_bytes().get(matches[0].column - 1..),
765 Some(b"needle".as_slice()),
766 "the column must index into the line text"
767 );
768 }
769
770 #[test]
771 fn multibyte_column_matches_ripgrep() {
772 let dir = tempdir().expect("tempdir");
773 fs::write(dir.path().join("cjk.txt"), "中文needle\n").expect("write");
776
777 let request = SearchRequest::literal("needle", dir.path().to_path_buf());
778 let matches = search(&request).expect("search runs");
779
780 assert_eq!(matches[0].column, 7, "column counts bytes, matching `rg --column`");
781 }
782
783 #[test]
784 fn line_start_marks_the_start_of_the_matching_line() {
785 let dir = tempdir().expect("tempdir");
786 fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
787
788 let request = SearchRequest::literal("needle", dir.path().to_path_buf());
789 let matches = search(&request).expect("search runs");
790
791 assert_eq!(matches[0].line_start, 6, "the second line starts after \"first\\n\"");
792 assert_eq!(
793 matches[0].byte_start - matches[0].line_start,
794 matches[0].column - 1,
795 "the file offset and the line offset must agree"
796 );
797 }
798
799 #[test]
800 fn invalid_regex_is_reported_as_an_error() {
801 let dir = fixture();
802 let request = SearchRequest::regex("(unclosed", dir.path().to_path_buf());
803
804 assert!(search(&request).is_err(), "an invalid regex must not silently match nothing");
805 }
806
807 #[test]
808 fn glob_matching_handles_wildcards_and_prefixes() {
809 assert!(glob_matches_path(Path::new("main.rs"), "*.rs"));
810 assert!(!glob_matches_path(Path::new("main.txt"), "*.rs"));
811 assert!(glob_matches_path(Path::new("main.rs"), "**/*.rs"));
812 assert!(glob_matches_path(Path::new("main.rs"), "main.rs"));
813 assert!(glob_matches_path(Path::new("a/b/main.rs"), "**/*.rs"));
814 assert!(!glob_matches_path(Path::new("a/b/main.txt"), "**/*.rs"));
815 assert!(glob_matches_path(Path::new("target/x/y.rs"), "**/target/**"));
816 }
817
818 #[test]
819 fn exclude_globs_remove_matching_files() {
820 let dir = fixture();
821 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("*.txt");
822 let matches = search(&request).expect("search runs");
823
824 assert_eq!(matches.len(), 1, "the excluded text file must not be scanned");
825 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
826 }
827
828 #[test]
829 fn exclude_globs_match_directory_patterns_at_any_depth() {
830 let dir = tempdir().expect("tempdir");
831 fs::create_dir_all(dir.path().join("target/nested")).expect("nested directory");
832 fs::write(dir.path().join("target/nested/build.rs"), "needle\n").expect("write nested");
833 fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
834
835 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("**/target/**");
836 let matches = search(&request).expect("search runs");
837
838 assert_eq!(matches.len(), 1, "the excluded directory must not be scanned");
839 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
840 }
841
842 #[test]
843 fn gitignored_files_are_skipped_by_default() {
844 let dir = tempdir().expect("tempdir");
845 fs::create_dir(dir.path().join(".git")).expect("git marker");
848 fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
849 fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
850 fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
851
852 let request = SearchRequest::literal("needle", dir.path().to_path_buf());
853 let matches = search(&request).expect("search runs");
854
855 assert_eq!(matches.len(), 1, "the ignored file must not be scanned");
856 assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
857 }
858
859 #[test]
860 fn ignore_files_can_be_disabled() {
861 let dir = tempdir().expect("tempdir");
862 fs::create_dir(dir.path().join(".git")).expect("git marker");
863 fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
864 fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
865
866 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).without_ignore_files();
867 let matches = search(&request).expect("search runs");
868
869 assert_eq!(matches.len(), 1, "disabling ignore files scans the ignored file");
870 }
871
872 #[test]
873 fn max_file_size_skips_large_files() {
874 let dir = tempdir().expect("tempdir");
875 fs::write(dir.path().join("big.txt"), "needle\n".repeat(100)).expect("write big");
876
877 let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_file_size(16);
878 let matches = search(&request).expect("search runs");
879
880 assert!(matches.is_empty(), "a file over the size limit must not be scanned");
881 }
882
883 #[test]
884 fn request_defaults_to_literal_mode() {
885 let request = SearchRequest::literal("x", "/tmp");
886 assert_eq!(request.mode, MatchMode::Literal);
887 }
888}