1use std::fs::File;
2use std::io::{BufReader, Read, Seek, SeekFrom};
3use std::num::NonZero;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6use std::thread::{self};
7
8use content_inspector::{ContentType, inspect};
9use fancy_regex::Regex as FancyRegex;
10use ignore::overrides::Override;
11use ignore::{WalkBuilder, WalkState};
12use regex::Regex;
13
14use crate::{
15 line_reader::{BufReadExt, LineEnding},
16 replace::{self, ReplaceResult},
17};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct SearchResult {
21 pub path: PathBuf,
22 pub line_number: usize,
24 pub line: String,
25 pub line_ending: LineEnding,
26 pub included: bool,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct SearchResultWithReplacement {
31 pub search_result: SearchResult,
32 pub replacement: String,
33 pub replace_result: Option<ReplaceResult>,
34}
35
36impl SearchResultWithReplacement {
37 pub fn display_error(&self) -> (String, &str) {
38 let error = match &self.replace_result {
39 Some(ReplaceResult::Error(error)) => error,
40 None => panic!("Found error result with no error message"),
41 Some(ReplaceResult::Success) => {
42 panic!("Found successful result in errors: {self:?}")
43 }
44 };
45
46 let path_display = format!(
47 "{}:{}",
48 self.search_result.path.display(),
49 self.search_result.line_number
50 );
51
52 (path_display, error)
53 }
54}
55
56#[derive(Clone, Debug)]
57pub enum SearchType {
58 Pattern(Regex),
59 PatternAdvanced(FancyRegex),
60 Fixed(String),
61}
62
63impl SearchType {
64 pub fn is_empty(&self) -> bool {
65 let str = match &self {
66 SearchType::Pattern(r) => &r.to_string(),
67 SearchType::PatternAdvanced(r) => &r.to_string(),
68 SearchType::Fixed(s) => s,
69 };
70 str.is_empty()
71 }
72}
73
74type FileVisitor = Box<dyn FnMut(Vec<SearchResult>) -> WalkState + Send>;
76
77#[derive(Clone, Debug)]
85pub struct FileSearcher {
86 search: SearchType,
87 replace: String,
88 overrides: Override,
89 root_dir: PathBuf,
90 include_hidden: bool,
91}
92
93impl FileSearcher {
94 pub fn search(&self) -> &SearchType {
95 &self.search
96 }
97
98 pub fn replace(&self) -> &String {
99 &self.replace
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct RegexOptions {
106 pub whole_word: bool,
108 pub match_case: bool,
110}
111
112#[derive(Clone, Debug)]
114pub struct FileSearcherConfig {
115 pub search: SearchType,
117 pub replace: String,
119 pub whole_word: bool,
121 pub match_case: bool,
123 pub overrides: Override,
125 pub root_dir: PathBuf,
127 pub include_hidden: bool,
129}
130
131impl FileSearcher {
132 pub fn new(config: FileSearcherConfig) -> Self {
138 let search = if !config.whole_word && config.match_case {
139 config.search
141 } else {
142 let options = RegexOptions {
143 whole_word: config.whole_word,
144 match_case: config.match_case,
145 };
146 Self::convert_regex(&config.search, &options)
147 };
148 Self {
149 search,
150 replace: config.replace,
151 overrides: config.overrides,
152 root_dir: config.root_dir,
153 include_hidden: config.include_hidden,
154 }
155 }
156
157 fn convert_regex(search: &SearchType, options: &RegexOptions) -> SearchType {
158 let mut search_regex_str = match search {
159 SearchType::Fixed(fixed_str) => regex::escape(fixed_str),
160 SearchType::Pattern(pattern) => pattern.as_str().to_owned(),
161 SearchType::PatternAdvanced(pattern) => pattern.as_str().to_owned(),
162 };
163
164 if options.whole_word {
165 search_regex_str = format!(r"(?<![a-zA-Z0-9_]){search_regex_str}(?![a-zA-Z0-9_])");
166 }
167 if !options.match_case {
168 search_regex_str = format!(r"(?i){search_regex_str}");
169 }
170
171 let fancy_regex = FancyRegex::new(&search_regex_str).unwrap();
174 SearchType::PatternAdvanced(fancy_regex)
175 }
176
177 fn build_walker(&self) -> ignore::WalkParallel {
178 let num_threads = thread::available_parallelism()
179 .map(NonZero::get)
180 .unwrap_or(4)
181 .min(12);
182
183 WalkBuilder::new(&self.root_dir)
184 .hidden(!self.include_hidden)
185 .overrides(self.overrides.clone())
186 .threads(num_threads)
187 .build_parallel()
188 }
189
190 pub fn walk_files<F>(&self, cancelled: Option<&AtomicBool>, mut file_handler: F)
243 where
244 F: FnMut() -> FileVisitor + Send,
245 {
246 if let Some(cancelled) = cancelled {
247 cancelled.store(false, Ordering::Relaxed);
248 }
249
250 let walker = self.build_walker();
251 walker.run(|| {
252 let mut on_file_found = file_handler();
253 Box::new(move |result| {
254 if let Some(cancelled) = cancelled {
255 if cancelled.load(Ordering::Relaxed) {
256 return WalkState::Quit;
257 }
258 }
259
260 let Ok(entry) = result else {
261 return WalkState::Continue;
262 };
263
264 if is_searchable(&entry) {
265 let results = match search_file(entry.path(), &self.search) {
266 Ok(r) => r,
267 Err(e) => {
268 log::warn!(
269 "Skipping {} due to error when searching: {e}",
270 entry.path().display()
271 );
272 return WalkState::Continue;
273 }
274 };
275
276 if !results.is_empty() {
277 return on_file_found(results);
278 }
279 }
280 WalkState::Continue
281 })
282 });
283 }
284
285 pub fn walk_files_and_replace(&self, cancelled: Option<&AtomicBool>) -> usize {
300 if let Some(cancelled) = cancelled {
301 cancelled.store(false, Ordering::Relaxed);
302 }
303
304 let num_files_replaced_in = std::sync::Arc::new(AtomicUsize::new(0));
305
306 let walker = self.build_walker();
307 walker.run(|| {
308 let counter = num_files_replaced_in.clone();
309
310 Box::new(move |result| {
311 if let Some(cancelled) = cancelled {
312 if cancelled.load(Ordering::Relaxed) {
313 return WalkState::Quit;
314 }
315 }
316
317 let Ok(entry) = result else {
318 return WalkState::Continue;
319 };
320
321 if is_searchable(&entry) {
322 match replace::replace_all_in_file(entry.path(), &self.search, &self.replace) {
323 Ok(replaced_in_file) => {
324 if replaced_in_file {
325 counter.fetch_add(1, Ordering::Relaxed);
326 }
327 }
328 Err(e) => {
329 log::error!(
330 "Found error when performing replacement in {path_display}: {e}",
331 path_display = entry.path().display()
332 );
333 }
334 }
335 }
336 WalkState::Continue
337 })
338 });
339
340 num_files_replaced_in.load(Ordering::Relaxed)
341 }
342}
343
344const BINARY_EXTENSIONS: &[&str] = &[
345 "png", "gif", "jpg", "jpeg", "ico", "svg", "pdf", "exe", "dll", "so", "bin", "class", "jar",
346 "zip", "gz", "bz2", "xz", "7z", "tar",
347];
348
349fn is_likely_binary(path: &Path) -> bool {
350 path.extension()
351 .and_then(|ext| ext.to_str())
352 .is_some_and(|ext_str| {
353 BINARY_EXTENSIONS
354 .iter()
355 .any(|&bin_ext| ext_str.eq_ignore_ascii_case(bin_ext))
356 })
357}
358
359fn is_searchable(entry: &ignore::DirEntry) -> bool {
360 entry.file_type().is_some_and(|ft| ft.is_file()) && !is_likely_binary(entry.path())
361}
362
363pub fn contains_search(line: &str, search: &SearchType) -> bool {
364 match search {
365 SearchType::Fixed(fixed_str) => line.contains(fixed_str),
366 SearchType::Pattern(pattern) => pattern.is_match(line),
367 SearchType::PatternAdvanced(pattern) => pattern.is_match(line).is_ok_and(|r| r),
368 }
369}
370
371pub fn search_file(path: &Path, search: &SearchType) -> anyhow::Result<Vec<SearchResult>> {
372 if search.is_empty() {
373 return Ok(vec![]);
374 }
375 let mut file = File::open(path)?;
376
377 let mut probe = [0u8; 8192];
379 let read = file.read(&mut probe).unwrap_or(0);
380 if matches!(inspect(&probe[..read]), ContentType::BINARY) {
381 return Ok(Vec::new());
382 }
383 file.seek(SeekFrom::Start(0))?;
384
385 let reader = BufReader::with_capacity(16384, file);
386 let mut results = Vec::new();
387
388 let mut read_errors = 0;
389
390 for (mut line_number, line_result) in reader.lines_with_endings().enumerate() {
391 line_number += 1; let (line_bytes, line_ending) = match line_result {
394 Ok(l) => l,
395 Err(err) => {
396 read_errors += 1;
397 log::warn!(
398 "Error retrieving line {line_number} of {}: {err}",
399 path.display()
400 );
401 if read_errors >= 10 {
402 anyhow::bail!(
403 "Aborting search of {path:?}: too many read errors ({read_errors}). Most recent error: {err}",
404 );
405 }
406 continue;
407 }
408 };
409
410 if let Ok(line) = String::from_utf8(line_bytes) {
411 if contains_search(&line, search) {
412 let result = SearchResult {
413 path: path.to_path_buf(),
414 line_number,
415 line,
416 line_ending,
417 included: true,
418 };
419 results.push(result);
420 }
421 }
422 }
423
424 Ok(results)
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 mod test_helpers {
432 use super::*;
433
434 pub fn create_test_search_result_with_replacement(
435 path: &str,
436 line_number: usize,
437 replace_result: Option<ReplaceResult>,
438 ) -> SearchResultWithReplacement {
439 SearchResultWithReplacement {
440 search_result: SearchResult {
441 path: PathBuf::from(path),
442 line_number,
443 line: "test line".to_string(),
444 line_ending: LineEnding::Lf,
445 included: true,
446 },
447 replacement: "replacement".to_string(),
448 replace_result,
449 }
450 }
451
452 pub fn create_fixed_search(term: &str) -> SearchType {
453 SearchType::Fixed(term.to_string())
454 }
455
456 pub fn create_pattern_search(pattern: &str) -> SearchType {
457 SearchType::Pattern(Regex::new(pattern).unwrap())
458 }
459
460 pub fn create_advanced_pattern_search(pattern: &str) -> SearchType {
461 SearchType::PatternAdvanced(FancyRegex::new(pattern).unwrap())
462 }
463
464 pub fn assert_pattern_contains(search_type: &SearchType, expected_parts: &[&str]) {
465 if let SearchType::PatternAdvanced(regex) = search_type {
466 let pattern = regex.as_str();
467 for part in expected_parts {
468 assert!(
469 pattern.contains(part),
470 "Pattern '{pattern}' should contain '{part}'"
471 );
472 }
473 } else {
474 panic!("Expected PatternAdvanced, got {search_type:?}");
475 }
476 }
477 }
478
479 mod regex_options_tests {
480 use super::*;
481
482 mod fixed_string_tests {
483 use super::*;
484
485 mod whole_word_true_match_case_true {
486 use super::*;
487
488 #[test]
489 fn test_basic_replacement() {
490 assert_eq!(
491 replace::replacement_if_match(
492 "hello world",
493 &FileSearcher::convert_regex(
494 &SearchType::Fixed("world".to_string()),
495 &RegexOptions {
496 whole_word: true,
497 match_case: true,
498 }
499 ),
500 "earth"
501 ),
502 Some("hello earth".to_string())
503 );
504 }
505
506 #[test]
507 fn test_case_sensitivity() {
508 assert_eq!(
509 replace::replacement_if_match(
510 "hello WORLD",
511 &FileSearcher::convert_regex(
512 &SearchType::Fixed("world".to_string()),
513 &RegexOptions {
514 whole_word: true,
515 match_case: true,
516 }
517 ),
518 "earth"
519 ),
520 None
521 );
522 }
523
524 #[test]
525 fn test_word_boundaries() {
526 assert_eq!(
527 replace::replacement_if_match(
528 "worldwide",
529 &FileSearcher::convert_regex(
530 &SearchType::Fixed("world".to_string()),
531 &RegexOptions {
532 whole_word: true,
533 match_case: true,
534 }
535 ),
536 "earth"
537 ),
538 None
539 );
540 }
541 }
542
543 mod whole_word_true_match_case_false {
544 use super::*;
545
546 #[test]
547 fn test_basic_replacement() {
548 assert_eq!(
549 replace::replacement_if_match(
550 "hello world",
551 &FileSearcher::convert_regex(
552 &SearchType::Fixed("world".to_string()),
553 &RegexOptions {
554 whole_word: true,
555 match_case: false,
556 }
557 ),
558 "earth"
559 ),
560 Some("hello earth".to_string())
561 );
562 }
563
564 #[test]
565 fn test_case_insensitivity() {
566 assert_eq!(
567 replace::replacement_if_match(
568 "hello WORLD",
569 &FileSearcher::convert_regex(
570 &SearchType::Fixed("world".to_string()),
571 &RegexOptions {
572 whole_word: true,
573 match_case: false,
574 }
575 ),
576 "earth"
577 ),
578 Some("hello earth".to_string())
579 );
580 }
581
582 #[test]
583 fn test_word_boundaries() {
584 assert_eq!(
585 replace::replacement_if_match(
586 "worldwide",
587 &FileSearcher::convert_regex(
588 &SearchType::Fixed("world".to_string()),
589 &RegexOptions {
590 whole_word: true,
591 match_case: false,
592 }
593 ),
594 "earth"
595 ),
596 None
597 );
598 }
599
600 #[test]
601 fn test_unicode() {
602 assert_eq!(
603 replace::replacement_if_match(
604 "Hello CAFÉ table",
605 &FileSearcher::convert_regex(
606 &SearchType::Fixed("café".to_string()),
607 &RegexOptions {
608 whole_word: true,
609 match_case: false,
610 }
611 ),
612 "restaurant"
613 ),
614 Some("Hello restaurant table".to_string())
615 );
616 }
617 }
618
619 mod whole_word_false_match_case_true {
620 use super::*;
621
622 #[test]
623 fn test_basic_replacement() {
624 assert_eq!(
625 replace::replacement_if_match(
626 "hello world",
627 &FileSearcher::convert_regex(
628 &SearchType::Fixed("world".to_string()),
629 &RegexOptions {
630 whole_word: false,
631 match_case: true,
632 }
633 ),
634 "earth"
635 ),
636 Some("hello earth".to_string())
637 );
638 }
639
640 #[test]
641 fn test_case_sensitivity() {
642 assert_eq!(
643 replace::replacement_if_match(
644 "hello WORLD",
645 &FileSearcher::convert_regex(
646 &SearchType::Fixed("world".to_string()),
647 &RegexOptions {
648 whole_word: false,
649 match_case: true,
650 }
651 ),
652 "earth"
653 ),
654 None
655 );
656 }
657
658 #[test]
659 fn test_substring_matches() {
660 assert_eq!(
661 replace::replacement_if_match(
662 "worldwide",
663 &FileSearcher::convert_regex(
664 &SearchType::Fixed("world".to_string()),
665 &RegexOptions {
666 whole_word: false,
667 match_case: true,
668 }
669 ),
670 "earth"
671 ),
672 Some("earthwide".to_string())
673 );
674 }
675 }
676
677 mod whole_word_false_match_case_false {
678 use super::*;
679
680 #[test]
681 fn test_basic_replacement() {
682 assert_eq!(
683 replace::replacement_if_match(
684 "hello world",
685 &FileSearcher::convert_regex(
686 &SearchType::Fixed("world".to_string()),
687 &RegexOptions {
688 whole_word: false,
689 match_case: false,
690 }
691 ),
692 "earth"
693 ),
694 Some("hello earth".to_string())
695 );
696 }
697
698 #[test]
699 fn test_case_insensitivity() {
700 assert_eq!(
701 replace::replacement_if_match(
702 "hello WORLD",
703 &FileSearcher::convert_regex(
704 &SearchType::Fixed("world".to_string()),
705 &RegexOptions {
706 whole_word: false,
707 match_case: false,
708 }
709 ),
710 "earth"
711 ),
712 Some("hello earth".to_string())
713 );
714 }
715
716 #[test]
717 fn test_substring_matches() {
718 assert_eq!(
719 replace::replacement_if_match(
720 "WORLDWIDE",
721 &FileSearcher::convert_regex(
722 &SearchType::Fixed("world".to_string()),
723 &RegexOptions {
724 whole_word: false,
725 match_case: false,
726 }
727 ),
728 "earth"
729 ),
730 Some("earthWIDE".to_string())
731 );
732 }
733 }
734 }
735
736 mod regex_pattern_tests {
737 use super::*;
738
739 mod whole_word_true_match_case_true {
740 use super::*;
741
742 #[test]
743 fn test_basic_regex() {
744 let re = Regex::new(r"w\w+d").unwrap();
745 assert_eq!(
746 replace::replacement_if_match(
747 "hello world",
748 &FileSearcher::convert_regex(
749 &SearchType::Pattern(re),
750 &RegexOptions {
751 whole_word: true,
752 match_case: true,
753 }
754 ),
755 "earth"
756 ),
757 Some("hello earth".to_string())
758 );
759 }
760
761 #[test]
762 fn test_case_sensitivity() {
763 let re = Regex::new(r"world").unwrap();
764 assert_eq!(
765 replace::replacement_if_match(
766 "hello WORLD",
767 &FileSearcher::convert_regex(
768 &SearchType::Pattern(re),
769 &RegexOptions {
770 whole_word: true,
771 match_case: true,
772 }
773 ),
774 "earth"
775 ),
776 None
777 );
778 }
779
780 #[test]
781 fn test_word_boundaries() {
782 let re = Regex::new(r"world").unwrap();
783 assert_eq!(
784 replace::replacement_if_match(
785 "worldwide",
786 &FileSearcher::convert_regex(
787 &SearchType::Pattern(re),
788 &RegexOptions {
789 whole_word: true,
790 match_case: true,
791 }
792 ),
793 "earth"
794 ),
795 None
796 );
797 }
798 }
799
800 mod whole_word_true_match_case_false {
801 use super::*;
802
803 #[test]
804 fn test_basic_regex() {
805 let re = Regex::new(r"w\w+d").unwrap();
806 assert_eq!(
807 replace::replacement_if_match(
808 "hello WORLD",
809 &FileSearcher::convert_regex(
810 &SearchType::Pattern(re),
811 &RegexOptions {
812 whole_word: true,
813 match_case: false,
814 }
815 ),
816 "earth"
817 ),
818 Some("hello earth".to_string())
819 );
820 }
821
822 #[test]
823 fn test_word_boundaries() {
824 let re = Regex::new(r"world").unwrap();
825 assert_eq!(
826 replace::replacement_if_match(
827 "worldwide",
828 &FileSearcher::convert_regex(
829 &SearchType::Pattern(re),
830 &RegexOptions {
831 whole_word: true,
832 match_case: false,
833 }
834 ),
835 "earth"
836 ),
837 None
838 );
839 }
840
841 #[test]
842 fn test_special_characters() {
843 let re = Regex::new(r"\d+").unwrap();
844 assert_eq!(
845 replace::replacement_if_match(
846 "test 123 number",
847 &FileSearcher::convert_regex(
848 &SearchType::Pattern(re),
849 &RegexOptions {
850 whole_word: true,
851 match_case: false,
852 }
853 ),
854 "NUM"
855 ),
856 Some("test NUM number".to_string())
857 );
858 }
859 }
860
861 mod whole_word_false_match_case_true {
862 use super::*;
863
864 #[test]
865 fn test_basic_regex() {
866 let re = Regex::new(r"w\w+d").unwrap();
867 assert_eq!(
868 replace::replacement_if_match(
869 "hello world",
870 &FileSearcher::convert_regex(
871 &SearchType::Pattern(re),
872 &RegexOptions {
873 whole_word: false,
874 match_case: true,
875 }
876 ),
877 "earth"
878 ),
879 Some("hello earth".to_string())
880 );
881 }
882
883 #[test]
884 fn test_case_sensitivity() {
885 let re = Regex::new(r"world").unwrap();
886 assert_eq!(
887 replace::replacement_if_match(
888 "hello WORLD",
889 &FileSearcher::convert_regex(
890 &SearchType::Pattern(re),
891 &RegexOptions {
892 whole_word: false,
893 match_case: true,
894 }
895 ),
896 "earth"
897 ),
898 None
899 );
900 }
901
902 #[test]
903 fn test_substring_matches() {
904 let re = Regex::new(r"world").unwrap();
905 assert_eq!(
906 replace::replacement_if_match(
907 "worldwide",
908 &FileSearcher::convert_regex(
909 &SearchType::Pattern(re),
910 &RegexOptions {
911 whole_word: false,
912 match_case: true,
913 }
914 ),
915 "earth"
916 ),
917 Some("earthwide".to_string())
918 );
919 }
920 }
921
922 mod whole_word_false_match_case_false {
923 use super::*;
924
925 #[test]
926 fn test_basic_regex() {
927 let re = Regex::new(r"w\w+d").unwrap();
928 assert_eq!(
929 replace::replacement_if_match(
930 "hello WORLD",
931 &FileSearcher::convert_regex(
932 &SearchType::Pattern(re),
933 &RegexOptions {
934 whole_word: false,
935 match_case: false,
936 }
937 ),
938 "earth"
939 ),
940 Some("hello earth".to_string())
941 );
942 }
943
944 #[test]
945 fn test_substring_matches() {
946 let re = Regex::new(r"world").unwrap();
947 assert_eq!(
948 replace::replacement_if_match(
949 "WORLDWIDE",
950 &FileSearcher::convert_regex(
951 &SearchType::Pattern(re),
952 &RegexOptions {
953 whole_word: false,
954 match_case: false,
955 }
956 ),
957 "earth"
958 ),
959 Some("earthWIDE".to_string())
960 );
961 }
962
963 #[test]
964 fn test_complex_pattern() {
965 let re = Regex::new(r"\d{3}-\d{2}-\d{4}").unwrap();
966 assert_eq!(
967 replace::replacement_if_match(
968 "SSN: 123-45-6789",
969 &FileSearcher::convert_regex(
970 &SearchType::Pattern(re),
971 &RegexOptions {
972 whole_word: false,
973 match_case: false,
974 }
975 ),
976 "XXX-XX-XXXX"
977 ),
978 Some("SSN: XXX-XX-XXXX".to_string())
979 );
980 }
981 }
982 }
983
984 mod fancy_regex_pattern_tests {
985 use super::*;
986
987 mod whole_word_true_match_case_true {
988 use super::*;
989
990 #[test]
991 fn test_lookbehind() {
992 let re = FancyRegex::new(r"(?<=@)\w+").unwrap();
993 assert_eq!(
994 replace::replacement_if_match(
995 "email: user@example.com",
996 &FileSearcher::convert_regex(
997 &SearchType::PatternAdvanced(re),
998 &RegexOptions {
999 whole_word: true,
1000 match_case: true,
1001 }
1002 ),
1003 "domain"
1004 ),
1005 Some("email: user@domain.com".to_string())
1006 );
1007 }
1008
1009 #[test]
1010 fn test_lookahead() {
1011 let re = FancyRegex::new(r"\w+(?=\.\w+$)").unwrap();
1012 assert_eq!(
1013 replace::replacement_if_match(
1014 "file: document.pdf",
1015 &FileSearcher::convert_regex(
1016 &SearchType::PatternAdvanced(re),
1017 &RegexOptions {
1018 whole_word: true,
1019 match_case: true,
1020 }
1021 ),
1022 "report"
1023 ),
1024 Some("file: report.pdf".to_string())
1025 );
1026 }
1027
1028 #[test]
1029 fn test_case_sensitivity() {
1030 let re = FancyRegex::new(r"world").unwrap();
1031 assert_eq!(
1032 replace::replacement_if_match(
1033 "hello WORLD",
1034 &FileSearcher::convert_regex(
1035 &SearchType::PatternAdvanced(re),
1036 &RegexOptions {
1037 whole_word: true,
1038 match_case: true,
1039 }
1040 ),
1041 "earth"
1042 ),
1043 None
1044 );
1045 }
1046 }
1047
1048 mod whole_word_true_match_case_false {
1049 use super::*;
1050
1051 #[test]
1052 fn test_lookbehind_case_insensitive() {
1053 let re = FancyRegex::new(r"(?<=@)\w+").unwrap();
1054 assert_eq!(
1055 replace::replacement_if_match(
1056 "email: user@EXAMPLE.com",
1057 &FileSearcher::convert_regex(
1058 &SearchType::PatternAdvanced(re),
1059 &RegexOptions {
1060 whole_word: true,
1061 match_case: false,
1062 }
1063 ),
1064 "domain"
1065 ),
1066 Some("email: user@domain.com".to_string())
1067 );
1068 }
1069
1070 #[test]
1071 fn test_word_boundaries() {
1072 let re = FancyRegex::new(r"world").unwrap();
1073 assert_eq!(
1074 replace::replacement_if_match(
1075 "worldwide",
1076 &FileSearcher::convert_regex(
1077 &SearchType::PatternAdvanced(re),
1078 &RegexOptions {
1079 whole_word: true,
1080 match_case: false,
1081 }
1082 ),
1083 "earth"
1084 ),
1085 None
1086 );
1087 }
1088 }
1089
1090 mod whole_word_false_match_case_true {
1091 use super::*;
1092
1093 #[test]
1094 fn test_complex_pattern() {
1095 let re = FancyRegex::new(r"(?<=\d{4}-\d{2}-\d{2}T)\d{2}:\d{2}").unwrap();
1096 assert_eq!(
1097 replace::replacement_if_match(
1098 "Timestamp: 2023-01-15T14:30:00Z",
1099 &FileSearcher::convert_regex(
1100 &SearchType::PatternAdvanced(re),
1101 &RegexOptions {
1102 whole_word: false,
1103 match_case: true,
1104 }
1105 ),
1106 "XX:XX"
1107 ),
1108 Some("Timestamp: 2023-01-15TXX:XX:00Z".to_string())
1109 );
1110 }
1111
1112 #[test]
1113 fn test_case_sensitivity() {
1114 let re = FancyRegex::new(r"WORLD").unwrap();
1115 assert_eq!(
1116 replace::replacement_if_match(
1117 "hello world",
1118 &FileSearcher::convert_regex(
1119 &SearchType::PatternAdvanced(re),
1120 &RegexOptions {
1121 whole_word: false,
1122 match_case: true,
1123 }
1124 ),
1125 "earth"
1126 ),
1127 None
1128 );
1129 }
1130 }
1131
1132 mod whole_word_false_match_case_false {
1133 use super::*;
1134
1135 #[test]
1136 fn test_complex_pattern_case_insensitive() {
1137 let re = FancyRegex::new(r"(?<=\[)\w+(?=\])").unwrap();
1138 assert_eq!(
1139 replace::replacement_if_match(
1140 "Tag: [WARNING] message",
1141 &FileSearcher::convert_regex(
1142 &SearchType::PatternAdvanced(re),
1143 &RegexOptions {
1144 whole_word: false,
1145 match_case: false,
1146 }
1147 ),
1148 "ERROR"
1149 ),
1150 Some("Tag: [ERROR] message".to_string())
1151 );
1152 }
1153
1154 #[test]
1155 fn test_unicode_support() {
1156 let re = FancyRegex::new(r"\p{Greek}+").unwrap();
1157 assert_eq!(
1158 replace::replacement_if_match(
1159 "Symbol: αβγδ",
1160 &FileSearcher::convert_regex(
1161 &SearchType::PatternAdvanced(re),
1162 &RegexOptions {
1163 whole_word: false,
1164 match_case: false,
1165 }
1166 ),
1167 "GREEK"
1168 ),
1169 Some("Symbol: GREEK".to_string())
1170 );
1171 }
1172 }
1173 }
1174
1175 #[test]
1176 fn test_multiple_replacements() {
1177 assert_eq!(
1178 replace::replacement_if_match(
1179 "world hello world",
1180 &FileSearcher::convert_regex(
1181 &SearchType::Fixed("world".to_string()),
1182 &RegexOptions {
1183 whole_word: true,
1184 match_case: false,
1185 }
1186 ),
1187 "earth"
1188 ),
1189 Some("earth hello earth".to_string())
1190 );
1191 }
1192
1193 #[test]
1194 fn test_no_match() {
1195 assert_eq!(
1196 replace::replacement_if_match(
1197 "worldwide",
1198 &FileSearcher::convert_regex(
1199 &SearchType::Fixed("world".to_string()),
1200 &RegexOptions {
1201 whole_word: true,
1202 match_case: false,
1203 }
1204 ),
1205 "earth"
1206 ),
1207 None
1208 );
1209 assert_eq!(
1210 replace::replacement_if_match(
1211 "_world_",
1212 &FileSearcher::convert_regex(
1213 &SearchType::Fixed("world".to_string()),
1214 &RegexOptions {
1215 whole_word: true,
1216 match_case: false,
1217 }
1218 ),
1219 "earth"
1220 ),
1221 None
1222 );
1223 }
1224
1225 #[test]
1226 fn test_word_boundaries() {
1227 assert_eq!(
1228 replace::replacement_if_match(
1229 ",world-",
1230 &FileSearcher::convert_regex(
1231 &SearchType::Fixed("world".to_string()),
1232 &RegexOptions {
1233 whole_word: true,
1234 match_case: false,
1235 }
1236 ),
1237 "earth"
1238 ),
1239 Some(",earth-".to_string())
1240 );
1241 assert_eq!(
1242 replace::replacement_if_match(
1243 "world-word",
1244 &FileSearcher::convert_regex(
1245 &SearchType::Fixed("world".to_string()),
1246 &RegexOptions {
1247 whole_word: true,
1248 match_case: false,
1249 }
1250 ),
1251 "earth"
1252 ),
1253 Some("earth-word".to_string())
1254 );
1255 assert_eq!(
1256 replace::replacement_if_match(
1257 "Hello-world!",
1258 &FileSearcher::convert_regex(
1259 &SearchType::Fixed("world".to_string()),
1260 &RegexOptions {
1261 whole_word: true,
1262 match_case: false,
1263 }
1264 ),
1265 "earth"
1266 ),
1267 Some("Hello-earth!".to_string())
1268 );
1269 }
1270
1271 #[test]
1272 fn test_case_sensitive() {
1273 assert_eq!(
1274 replace::replacement_if_match(
1275 "Hello WORLD",
1276 &FileSearcher::convert_regex(
1277 &SearchType::Fixed("world".to_string()),
1278 &RegexOptions {
1279 whole_word: true,
1280 match_case: true,
1281 }
1282 ),
1283 "earth"
1284 ),
1285 None
1286 );
1287 assert_eq!(
1288 replace::replacement_if_match(
1289 "Hello world",
1290 &FileSearcher::convert_regex(
1291 &SearchType::Fixed("wOrld".to_string()),
1292 &RegexOptions {
1293 whole_word: true,
1294 match_case: true,
1295 }
1296 ),
1297 "earth"
1298 ),
1299 None
1300 );
1301 }
1302
1303 #[test]
1304 fn test_empty_strings() {
1305 assert_eq!(
1306 replace::replacement_if_match(
1307 "",
1308 &FileSearcher::convert_regex(
1309 &SearchType::Fixed("world".to_string()),
1310 &RegexOptions {
1311 whole_word: true,
1312 match_case: false,
1313 }
1314 ),
1315 "earth"
1316 ),
1317 None
1318 );
1319 assert_eq!(
1320 replace::replacement_if_match(
1321 "hello world",
1322 &FileSearcher::convert_regex(
1323 &SearchType::Fixed("".to_string()),
1324 &RegexOptions {
1325 whole_word: true,
1326 match_case: false,
1327 }
1328 ),
1329 "earth"
1330 ),
1331 None
1332 );
1333 }
1334
1335 #[test]
1336 fn test_substring_no_match() {
1337 assert_eq!(
1338 replace::replacement_if_match(
1339 "worldwide web",
1340 &FileSearcher::convert_regex(
1341 &SearchType::Fixed("world".to_string()),
1342 &RegexOptions {
1343 whole_word: true,
1344 match_case: false,
1345 }
1346 ),
1347 "earth"
1348 ),
1349 None
1350 );
1351 assert_eq!(
1352 replace::replacement_if_match(
1353 "underworld",
1354 &FileSearcher::convert_regex(
1355 &SearchType::Fixed("world".to_string()),
1356 &RegexOptions {
1357 whole_word: true,
1358 match_case: false,
1359 }
1360 ),
1361 "earth"
1362 ),
1363 None
1364 );
1365 }
1366
1367 #[test]
1368 fn test_special_regex_chars() {
1369 assert_eq!(
1370 replace::replacement_if_match(
1371 "hello (world)",
1372 &FileSearcher::convert_regex(
1373 &SearchType::Fixed("(world)".to_string()),
1374 &RegexOptions {
1375 whole_word: true,
1376 match_case: false,
1377 }
1378 ),
1379 "earth"
1380 ),
1381 Some("hello earth".to_string())
1382 );
1383 assert_eq!(
1384 replace::replacement_if_match(
1385 "hello world.*",
1386 &FileSearcher::convert_regex(
1387 &SearchType::Fixed("world.*".to_string()),
1388 &RegexOptions {
1389 whole_word: true,
1390 match_case: false,
1391 }
1392 ),
1393 "ea+rth"
1394 ),
1395 Some("hello ea+rth".to_string())
1396 );
1397 }
1398
1399 #[test]
1400 fn test_basic_regex_patterns() {
1401 let re = Regex::new(r"ax*b").unwrap();
1402 assert_eq!(
1403 replace::replacement_if_match(
1404 "foo axxxxb bar",
1405 &FileSearcher::convert_regex(
1406 &SearchType::Pattern(re.clone()),
1407 &RegexOptions {
1408 whole_word: true,
1409 match_case: false,
1410 }
1411 ),
1412 "NEW"
1413 ),
1414 Some("foo NEW bar".to_string())
1415 );
1416 assert_eq!(
1417 replace::replacement_if_match(
1418 "fooaxxxxb bar",
1419 &FileSearcher::convert_regex(
1420 &SearchType::Pattern(re),
1421 &RegexOptions {
1422 whole_word: true,
1423 match_case: false,
1424 }
1425 ),
1426 "NEW"
1427 ),
1428 None
1429 );
1430 }
1431
1432 #[test]
1433 fn test_patterns_with_spaces() {
1434 let re = Regex::new(r"hel+o world").unwrap();
1435 assert_eq!(
1436 replace::replacement_if_match(
1437 "say hello world!",
1438 &FileSearcher::convert_regex(
1439 &SearchType::Pattern(re.clone()),
1440 &RegexOptions {
1441 whole_word: true,
1442 match_case: false,
1443 }
1444 ),
1445 "hi earth"
1446 ),
1447 Some("say hi earth!".to_string())
1448 );
1449 assert_eq!(
1450 replace::replacement_if_match(
1451 "helloworld",
1452 &FileSearcher::convert_regex(
1453 &SearchType::Pattern(re),
1454 &RegexOptions {
1455 whole_word: true,
1456 match_case: false,
1457 }
1458 ),
1459 "hi earth"
1460 ),
1461 None
1462 );
1463 }
1464
1465 #[test]
1466 fn test_multiple_matches() {
1467 let re = Regex::new(r"a+b+").unwrap();
1468 assert_eq!(
1469 replace::replacement_if_match(
1470 "foo aab abb",
1471 &FileSearcher::convert_regex(
1472 &SearchType::Pattern(re.clone()),
1473 &RegexOptions {
1474 whole_word: true,
1475 match_case: false,
1476 }
1477 ),
1478 "X"
1479 ),
1480 Some("foo X X".to_string())
1481 );
1482 assert_eq!(
1483 replace::replacement_if_match(
1484 "ab abaab abb",
1485 &FileSearcher::convert_regex(
1486 &SearchType::Pattern(re.clone()),
1487 &RegexOptions {
1488 whole_word: true,
1489 match_case: false,
1490 }
1491 ),
1492 "X"
1493 ),
1494 Some("X abaab X".to_string())
1495 );
1496 assert_eq!(
1497 replace::replacement_if_match(
1498 "ababaababb",
1499 &FileSearcher::convert_regex(
1500 &SearchType::Pattern(re.clone()),
1501 &RegexOptions {
1502 whole_word: true,
1503 match_case: false,
1504 }
1505 ),
1506 "X"
1507 ),
1508 None
1509 );
1510 assert_eq!(
1511 replace::replacement_if_match(
1512 "ab ab aab abb",
1513 &FileSearcher::convert_regex(
1514 &SearchType::Pattern(re),
1515 &RegexOptions {
1516 whole_word: true,
1517 match_case: false,
1518 }
1519 ),
1520 "X"
1521 ),
1522 Some("X X X X".to_string())
1523 );
1524 }
1525
1526 #[test]
1527 fn test_boundary_cases() {
1528 let re = Regex::new(r"foo\s*bar").unwrap();
1529 assert_eq!(
1531 replace::replacement_if_match(
1532 "foo bar baz",
1533 &FileSearcher::convert_regex(
1534 &SearchType::Pattern(re.clone()),
1535 &RegexOptions {
1536 whole_word: true,
1537 match_case: false,
1538 }
1539 ),
1540 "TEST"
1541 ),
1542 Some("TEST baz".to_string())
1543 );
1544 assert_eq!(
1546 replace::replacement_if_match(
1547 "baz foo bar",
1548 &FileSearcher::convert_regex(
1549 &SearchType::Pattern(re.clone()),
1550 &RegexOptions {
1551 whole_word: true,
1552 match_case: false,
1553 }
1554 ),
1555 "TEST"
1556 ),
1557 Some("baz TEST".to_string())
1558 );
1559 assert_eq!(
1561 replace::replacement_if_match(
1562 "(foo bar)",
1563 &FileSearcher::convert_regex(
1564 &SearchType::Pattern(re),
1565 &RegexOptions {
1566 whole_word: true,
1567 match_case: false,
1568 }
1569 ),
1570 "TEST"
1571 ),
1572 Some("(TEST)".to_string())
1573 );
1574 }
1575
1576 #[test]
1577 fn test_with_punctuation() {
1578 let re = Regex::new(r"a\d+b").unwrap();
1579 assert_eq!(
1580 replace::replacement_if_match(
1581 "(a123b)",
1582 &FileSearcher::convert_regex(
1583 &SearchType::Pattern(re.clone()),
1584 &RegexOptions {
1585 whole_word: true,
1586 match_case: false,
1587 }
1588 ),
1589 "X"
1590 ),
1591 Some("(X)".to_string())
1592 );
1593 assert_eq!(
1594 replace::replacement_if_match(
1595 "foo.a123b!bar",
1596 &FileSearcher::convert_regex(
1597 &SearchType::Pattern(re),
1598 &RegexOptions {
1599 whole_word: true,
1600 match_case: false,
1601 }
1602 ),
1603 "X"
1604 ),
1605 Some("foo.X!bar".to_string())
1606 );
1607 }
1608
1609 #[test]
1610 fn test_complex_patterns() {
1611 let re = Regex::new(r"[a-z]+\d+[a-z]+").unwrap();
1612 assert_eq!(
1613 replace::replacement_if_match(
1614 "test9 abc123def 8xyz",
1615 &FileSearcher::convert_regex(
1616 &SearchType::Pattern(re.clone()),
1617 &RegexOptions {
1618 whole_word: true,
1619 match_case: false,
1620 }
1621 ),
1622 "NEW"
1623 ),
1624 Some("test9 NEW 8xyz".to_string())
1625 );
1626 assert_eq!(
1627 replace::replacement_if_match(
1628 "test9abc123def8xyz",
1629 &FileSearcher::convert_regex(
1630 &SearchType::Pattern(re),
1631 &RegexOptions {
1632 whole_word: true,
1633 match_case: false,
1634 }
1635 ),
1636 "NEW"
1637 ),
1638 None
1639 );
1640 }
1641
1642 #[test]
1643 fn test_optional_patterns() {
1644 let re = Regex::new(r"colou?r").unwrap();
1645 assert_eq!(
1646 replace::replacement_if_match(
1647 "my color and colour",
1648 &FileSearcher::convert_regex(
1649 &SearchType::Pattern(re),
1650 &RegexOptions {
1651 whole_word: true,
1652 match_case: false,
1653 }
1654 ),
1655 "X"
1656 ),
1657 Some("my X and X".to_string())
1658 );
1659 }
1660
1661 #[test]
1662 fn test_empty_haystack() {
1663 let re = Regex::new(r"test").unwrap();
1664 assert_eq!(
1665 replace::replacement_if_match(
1666 "",
1667 &FileSearcher::convert_regex(
1668 &SearchType::Pattern(re),
1669 &RegexOptions {
1670 whole_word: true,
1671 match_case: false,
1672 }
1673 ),
1674 "NEW"
1675 ),
1676 None
1677 );
1678 }
1679
1680 #[test]
1681 fn test_empty_search_regex() {
1682 let re = Regex::new(r"").unwrap();
1683 assert_eq!(
1684 replace::replacement_if_match(
1685 "search",
1686 &FileSearcher::convert_regex(
1687 &SearchType::Pattern(re),
1688 &RegexOptions {
1689 whole_word: true,
1690 match_case: false,
1691 }
1692 ),
1693 "NEW"
1694 ),
1695 None
1696 );
1697 }
1698
1699 #[test]
1700 fn test_single_char() {
1701 let re = Regex::new(r"a").unwrap();
1702 assert_eq!(
1703 replace::replacement_if_match(
1704 "b a c",
1705 &FileSearcher::convert_regex(
1706 &SearchType::Pattern(re.clone()),
1707 &RegexOptions {
1708 whole_word: true,
1709 match_case: false,
1710 }
1711 ),
1712 "X"
1713 ),
1714 Some("b X c".to_string())
1715 );
1716 assert_eq!(
1717 replace::replacement_if_match(
1718 "bac",
1719 &FileSearcher::convert_regex(
1720 &SearchType::Pattern(re),
1721 &RegexOptions {
1722 whole_word: true,
1723 match_case: false,
1724 }
1725 ),
1726 "X"
1727 ),
1728 None
1729 );
1730 }
1731
1732 #[test]
1733 fn test_escaped_chars() {
1734 let re = Regex::new(r"\(\d+\)").unwrap();
1735 assert_eq!(
1736 replace::replacement_if_match(
1737 "test (123) foo",
1738 &FileSearcher::convert_regex(
1739 &SearchType::Pattern(re),
1740 &RegexOptions {
1741 whole_word: true,
1742 match_case: false,
1743 }
1744 ),
1745 "X"
1746 ),
1747 Some("test X foo".to_string())
1748 );
1749 }
1750
1751 #[test]
1752 fn test_with_unicode() {
1753 let re = Regex::new(r"λ\d+").unwrap();
1754 assert_eq!(
1755 replace::replacement_if_match(
1756 "calc λ123 β",
1757 &FileSearcher::convert_regex(
1758 &SearchType::Pattern(re.clone()),
1759 &RegexOptions {
1760 whole_word: true,
1761 match_case: false,
1762 }
1763 ),
1764 "X"
1765 ),
1766 Some("calc X β".to_string())
1767 );
1768 assert_eq!(
1769 replace::replacement_if_match(
1770 "calcλ123",
1771 &FileSearcher::convert_regex(
1772 &SearchType::Pattern(re),
1773 &RegexOptions {
1774 whole_word: true,
1775 match_case: false,
1776 }
1777 ),
1778 "X"
1779 ),
1780 None
1781 );
1782 }
1783
1784 #[test]
1785 fn test_multiline_patterns() {
1786 let re = Regex::new(r"foo\s*\n\s*bar").unwrap();
1787 assert_eq!(
1788 replace::replacement_if_match(
1789 "test foo\nbar end",
1790 &FileSearcher::convert_regex(
1791 &SearchType::Pattern(re.clone()),
1792 &RegexOptions {
1793 whole_word: true,
1794 match_case: false,
1795 }
1796 ),
1797 "NEW"
1798 ),
1799 Some("test NEW end".to_string())
1800 );
1801 assert_eq!(
1802 replace::replacement_if_match(
1803 "test foo\n bar end",
1804 &FileSearcher::convert_regex(
1805 &SearchType::Pattern(re),
1806 &RegexOptions {
1807 whole_word: true,
1808 match_case: false,
1809 }
1810 ),
1811 "NEW"
1812 ),
1813 Some("test NEW end".to_string())
1814 );
1815 }
1816 }
1817
1818 mod unicode_handling {
1819 use super::*;
1820
1821 #[test]
1822 fn test_complex_unicode_replacement() {
1823 let text = "ASCII text with 世界 (CJK), Здравствуйте (Cyrillic), 안녕하세요 (Hangul), αβγδ (Greek), עִבְרִית (Hebrew)";
1824 let search = SearchType::Fixed("世界".to_string());
1825
1826 let result = replace::replacement_if_match(text, &search, "World");
1827
1828 assert_eq!(
1829 result,
1830 Some("ASCII text with World (CJK), Здравствуйте (Cyrillic), 안녕하세요 (Hangul), αβγδ (Greek), עִבְרִית (Hebrew)".to_string())
1831 );
1832 }
1833
1834 #[test]
1835 fn test_unicode_word_boundaries() {
1836 let pattern = SearchType::Pattern(Regex::new(r"\b\p{Script=Han}{2}\b").unwrap());
1837 let converted = FileSearcher::convert_regex(
1838 &pattern,
1839 &RegexOptions {
1840 whole_word: true,
1841 match_case: false,
1842 },
1843 );
1844
1845 assert!(replace::replacement_if_match("Text 世界 more", &converted, "XX").is_some());
1846 assert!(replace::replacement_if_match("Text世界more", &converted, "XX").is_none());
1847 }
1848
1849 #[test]
1850 fn test_unicode_normalization() {
1851 let text = "café";
1852 let search = SearchType::Fixed("é".to_string());
1853 assert_eq!(
1854 replace::replacement_if_match(text, &search, "e"),
1855 Some("cafe".to_string())
1856 );
1857 }
1858
1859 #[test]
1860 fn test_unicode_regex_classes() {
1861 let text = "Latin A, Cyrillic Б, Greek Γ, Hebrew א";
1862
1863 let search = SearchType::Pattern(Regex::new(r"\p{Cyrillic}").unwrap());
1864 assert_eq!(
1865 replace::replacement_if_match(text, &search, "X"),
1866 Some("Latin A, Cyrillic X, Greek Γ, Hebrew א".to_string())
1867 );
1868
1869 let search = SearchType::Pattern(Regex::new(r"\p{Greek}").unwrap());
1870 assert_eq!(
1871 replace::replacement_if_match(text, &search, "X"),
1872 Some("Latin A, Cyrillic Б, Greek X, Hebrew א".to_string())
1873 );
1874 }
1875
1876 #[test]
1877 fn test_unicode_capture_groups() {
1878 let text = "Name: 李明 (ID: A12345)";
1879
1880 let search =
1881 SearchType::Pattern(Regex::new(r"Name: (\p{Han}+) \(ID: ([A-Z0-9]+)\)").unwrap());
1882 assert_eq!(
1883 replace::replacement_if_match(text, &search, "ID $2 belongs to $1"),
1884 Some("ID A12345 belongs to 李明".to_string())
1885 );
1886 }
1887 }
1888
1889 mod replace_any {
1890 use super::*;
1891
1892 #[test]
1893 fn test_simple_match_subword() {
1894 assert_eq!(
1895 replace::replacement_if_match(
1896 "foobarbaz",
1897 &SearchType::Fixed("bar".to_string()),
1898 "REPL"
1899 ),
1900 Some("fooREPLbaz".to_string())
1901 );
1902 assert_eq!(
1903 replace::replacement_if_match(
1904 "foobarbaz",
1905 &SearchType::Pattern(Regex::new(r"bar").unwrap()),
1906 "REPL"
1907 ),
1908 Some("fooREPLbaz".to_string())
1909 );
1910 assert_eq!(
1911 replace::replacement_if_match(
1912 "foobarbaz",
1913 &SearchType::PatternAdvanced(FancyRegex::new(r"bar").unwrap()),
1914 "REPL"
1915 ),
1916 Some("fooREPLbaz".to_string())
1917 );
1918 }
1919
1920 #[test]
1921 fn test_no_match() {
1922 assert_eq!(
1923 replace::replacement_if_match(
1924 "foobarbaz",
1925 &SearchType::Fixed("xyz".to_string()),
1926 "REPL"
1927 ),
1928 None
1929 );
1930 assert_eq!(
1931 replace::replacement_if_match(
1932 "foobarbaz",
1933 &SearchType::Pattern(Regex::new(r"xyz").unwrap()),
1934 "REPL"
1935 ),
1936 None
1937 );
1938 assert_eq!(
1939 replace::replacement_if_match(
1940 "foobarbaz",
1941 &SearchType::PatternAdvanced(FancyRegex::new(r"xyz").unwrap()),
1942 "REPL"
1943 ),
1944 None
1945 );
1946 }
1947
1948 #[test]
1949 fn test_word_boundaries() {
1950 assert_eq!(
1951 replace::replacement_if_match(
1952 "foo bar baz",
1953 &SearchType::Pattern(Regex::new(r"\bbar\b").unwrap()),
1954 "REPL"
1955 ),
1956 Some("foo REPL baz".to_string())
1957 );
1958 assert_eq!(
1959 replace::replacement_if_match(
1960 "embargo",
1961 &SearchType::Pattern(Regex::new(r"\bbar\b").unwrap()),
1962 "REPL"
1963 ),
1964 None
1965 );
1966 assert_eq!(
1967 replace::replacement_if_match(
1968 "foo bar baz",
1969 &SearchType::PatternAdvanced(FancyRegex::new(r"\bbar\b").unwrap()),
1970 "REPL"
1971 ),
1972 Some("foo REPL baz".to_string())
1973 );
1974 assert_eq!(
1975 replace::replacement_if_match(
1976 "embargo",
1977 &SearchType::PatternAdvanced(FancyRegex::new(r"\bbar\b").unwrap()),
1978 "REPL"
1979 ),
1980 None
1981 );
1982 }
1983
1984 #[test]
1985 fn test_capture_groups() {
1986 assert_eq!(
1987 replace::replacement_if_match(
1988 "John Doe",
1989 &SearchType::Pattern(Regex::new(r"(\w+)\s+(\w+)").unwrap()),
1990 "$2, $1"
1991 ),
1992 Some("Doe, John".to_string())
1993 );
1994 assert_eq!(
1995 replace::replacement_if_match(
1996 "John Doe",
1997 &SearchType::PatternAdvanced(FancyRegex::new(r"(\w+)\s+(\w+)").unwrap()),
1998 "$2, $1"
1999 ),
2000 Some("Doe, John".to_string())
2001 );
2002 }
2003
2004 #[test]
2005 fn test_lookaround() {
2006 assert_eq!(
2007 replace::replacement_if_match(
2008 "123abc456",
2009 &SearchType::PatternAdvanced(
2010 FancyRegex::new(r"(?<=\d{3})abc(?=\d{3})").unwrap()
2011 ),
2012 "REPL"
2013 ),
2014 Some("123REPL456".to_string())
2015 );
2016 }
2017
2018 #[test]
2019 fn test_quantifiers() {
2020 assert_eq!(
2021 replace::replacement_if_match(
2022 "aaa123456bbb",
2023 &SearchType::Pattern(Regex::new(r"\d+").unwrap()),
2024 "REPL"
2025 ),
2026 Some("aaaREPLbbb".to_string())
2027 );
2028 assert_eq!(
2029 replace::replacement_if_match(
2030 "abc123def456",
2031 &SearchType::Pattern(Regex::new(r"\d{3}").unwrap()),
2032 "REPL"
2033 ),
2034 Some("abcREPLdefREPL".to_string())
2035 );
2036 assert_eq!(
2037 replace::replacement_if_match(
2038 "aaa123456bbb",
2039 &SearchType::PatternAdvanced(FancyRegex::new(r"\d+").unwrap()),
2040 "REPL"
2041 ),
2042 Some("aaaREPLbbb".to_string())
2043 );
2044 assert_eq!(
2045 replace::replacement_if_match(
2046 "abc123def456",
2047 &SearchType::PatternAdvanced(FancyRegex::new(r"\d{3}").unwrap()),
2048 "REPL"
2049 ),
2050 Some("abcREPLdefREPL".to_string())
2051 );
2052 }
2053
2054 #[test]
2055 fn test_special_characters() {
2056 assert_eq!(
2057 replace::replacement_if_match(
2058 "foo.bar*baz",
2059 &SearchType::Fixed(".bar*".to_string()),
2060 "REPL"
2061 ),
2062 Some("fooREPLbaz".to_string())
2063 );
2064 assert_eq!(
2065 replace::replacement_if_match(
2066 "foo.bar*baz",
2067 &SearchType::Pattern(Regex::new(r"\.bar\*").unwrap()),
2068 "REPL"
2069 ),
2070 Some("fooREPLbaz".to_string())
2071 );
2072 assert_eq!(
2073 replace::replacement_if_match(
2074 "foo.bar*baz",
2075 &SearchType::PatternAdvanced(FancyRegex::new(r"\.bar\*").unwrap()),
2076 "REPL"
2077 ),
2078 Some("fooREPLbaz".to_string())
2079 );
2080 }
2081
2082 #[test]
2083 fn test_unicode() {
2084 assert_eq!(
2085 replace::replacement_if_match(
2086 "Hello 世界!",
2087 &SearchType::Fixed("世界".to_string()),
2088 "REPL"
2089 ),
2090 Some("Hello REPL!".to_string())
2091 );
2092 assert_eq!(
2093 replace::replacement_if_match(
2094 "Hello 世界!",
2095 &SearchType::Pattern(Regex::new(r"世界").unwrap()),
2096 "REPL"
2097 ),
2098 Some("Hello REPL!".to_string())
2099 );
2100 assert_eq!(
2101 replace::replacement_if_match(
2102 "Hello 世界!",
2103 &SearchType::PatternAdvanced(FancyRegex::new(r"世界").unwrap()),
2104 "REPL"
2105 ),
2106 Some("Hello REPL!".to_string())
2107 );
2108 }
2109
2110 #[test]
2111 fn test_case_insensitive() {
2112 assert_eq!(
2113 replace::replacement_if_match(
2114 "HELLO world",
2115 &SearchType::Pattern(Regex::new(r"(?i)hello").unwrap()),
2116 "REPL"
2117 ),
2118 Some("REPL world".to_string())
2119 );
2120 assert_eq!(
2121 replace::replacement_if_match(
2122 "HELLO world",
2123 &SearchType::PatternAdvanced(FancyRegex::new(r"(?i)hello").unwrap()),
2124 "REPL"
2125 ),
2126 Some("REPL world".to_string())
2127 );
2128 }
2129 }
2130
2131 mod search_result_tests {
2132 use super::*;
2133
2134 #[test]
2135 fn test_display_error_with_error_result() {
2136 let result = test_helpers::create_test_search_result_with_replacement(
2137 "/path/to/file.txt",
2138 42,
2139 Some(ReplaceResult::Error("Test error message".to_string())),
2140 );
2141
2142 let (path_display, error) = result.display_error();
2143
2144 assert_eq!(path_display, "/path/to/file.txt:42");
2145 assert_eq!(error, "Test error message");
2146 }
2147
2148 #[test]
2149 fn test_display_error_with_unicode_path() {
2150 let result = test_helpers::create_test_search_result_with_replacement(
2151 "/path/to/файл.txt",
2152 123,
2153 Some(ReplaceResult::Error("Unicode test".to_string())),
2154 );
2155
2156 let (path_display, error) = result.display_error();
2157
2158 assert_eq!(path_display, "/path/to/файл.txt:123");
2159 assert_eq!(error, "Unicode test");
2160 }
2161
2162 #[test]
2163 fn test_display_error_with_complex_error_message() {
2164 let complex_error = "Failed to write: Permission denied (os error 13)";
2165 let result = test_helpers::create_test_search_result_with_replacement(
2166 "/readonly/file.txt",
2167 1,
2168 Some(ReplaceResult::Error(complex_error.to_string())),
2169 );
2170
2171 let (path_display, error) = result.display_error();
2172
2173 assert_eq!(path_display, "/readonly/file.txt:1");
2174 assert_eq!(error, complex_error);
2175 }
2176
2177 #[test]
2178 #[should_panic(expected = "Found error result with no error message")]
2179 fn test_display_error_panics_with_none_result() {
2180 let result = test_helpers::create_test_search_result_with_replacement(
2181 "/path/to/file.txt",
2182 1,
2183 None,
2184 );
2185 result.display_error();
2186 }
2187
2188 #[test]
2189 #[should_panic(expected = "Found successful result in errors")]
2190 fn test_display_error_panics_with_success_result() {
2191 let result = test_helpers::create_test_search_result_with_replacement(
2192 "/path/to/file.txt",
2193 1,
2194 Some(ReplaceResult::Success),
2195 );
2196 result.display_error();
2197 }
2198 }
2199
2200 mod search_type_tests {
2201 use super::*;
2202
2203 #[test]
2204 fn test_search_type_emptiness() {
2205 let test_cases = [
2206 (test_helpers::create_fixed_search(""), true),
2207 (test_helpers::create_fixed_search("hello"), false),
2208 (test_helpers::create_fixed_search(" "), false), (test_helpers::create_pattern_search(""), true),
2210 (test_helpers::create_pattern_search("test"), false),
2211 (test_helpers::create_pattern_search(r"\s+"), false),
2212 (test_helpers::create_advanced_pattern_search(""), true),
2213 (test_helpers::create_advanced_pattern_search("test"), false),
2214 ];
2215
2216 for (search_type, expected_empty) in test_cases {
2217 assert_eq!(
2218 search_type.is_empty(),
2219 expected_empty,
2220 "Emptiness test failed for: {search_type:?}"
2221 );
2222 }
2223 }
2224 }
2225
2226 mod file_searcher_tests {
2227 use super::*;
2228
2229 #[test]
2230 fn test_convert_regex_whole_word() {
2231 let fixed_search = test_helpers::create_fixed_search("test");
2232 let converted = FileSearcher::convert_regex(
2233 &fixed_search,
2234 &RegexOptions {
2235 whole_word: true,
2236 match_case: true,
2237 },
2238 );
2239
2240 test_helpers::assert_pattern_contains(
2241 &converted,
2242 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "test"],
2243 );
2244 }
2245
2246 #[test]
2247 fn test_convert_regex_case_insensitive() {
2248 let fixed_search = test_helpers::create_fixed_search("Test");
2249 let converted = FileSearcher::convert_regex(
2250 &fixed_search,
2251 &RegexOptions {
2252 whole_word: false,
2253 match_case: false,
2254 },
2255 );
2256
2257 test_helpers::assert_pattern_contains(&converted, &["(?i)", "Test"]);
2258 }
2259
2260 #[test]
2261 fn test_convert_regex_whole_word_and_case_insensitive() {
2262 let fixed_search = test_helpers::create_fixed_search("Test");
2263 let converted = FileSearcher::convert_regex(
2264 &fixed_search,
2265 &RegexOptions {
2266 whole_word: true,
2267 match_case: false,
2268 },
2269 );
2270
2271 test_helpers::assert_pattern_contains(
2272 &converted,
2273 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "(?i)", "Test"],
2274 );
2275 }
2276
2277 #[test]
2278 fn test_convert_regex_escapes_special_chars() {
2279 let fixed_search = test_helpers::create_fixed_search("test.regex*");
2280 let converted = FileSearcher::convert_regex(
2281 &fixed_search,
2282 &RegexOptions {
2283 whole_word: false,
2284 match_case: true,
2285 },
2286 );
2287
2288 test_helpers::assert_pattern_contains(&converted, &[r"test\.regex\*"]);
2289 }
2290
2291 #[test]
2292 fn test_convert_regex_from_existing_pattern() {
2293 let pattern_search = test_helpers::create_pattern_search(r"\d+");
2294 let converted = FileSearcher::convert_regex(
2295 &pattern_search,
2296 &RegexOptions {
2297 whole_word: true,
2298 match_case: false,
2299 },
2300 );
2301
2302 test_helpers::assert_pattern_contains(
2303 &converted,
2304 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "(?i)", r"\d+"],
2305 );
2306 }
2307
2308 #[test]
2309 fn test_is_likely_binary_extensions() {
2310 const BINARY_EXTENSIONS: &[&str] = &[
2311 "image.png",
2312 "document.pdf",
2313 "archive.zip",
2314 "program.exe",
2315 "library.dll",
2316 "photo.jpg",
2317 "icon.ico",
2318 "vector.svg",
2319 "compressed.gz",
2320 "backup.7z",
2321 "java.class",
2322 "application.jar",
2323 ];
2324
2325 const TEXT_EXTENSIONS: &[&str] = &[
2326 "code.rs",
2327 "script.py",
2328 "document.txt",
2329 "config.json",
2330 "readme.md",
2331 "style.css",
2332 "page.html",
2333 "source.c",
2334 "header.h",
2335 "makefile",
2336 "no_extension",
2337 ];
2338
2339 const MIXED_CASE_BINARY: &[&str] =
2340 &["IMAGE.PNG", "Document.PDF", "ARCHIVE.ZIP", "Photo.JPG"];
2341
2342 let test_cases = [
2343 (BINARY_EXTENSIONS, true),
2344 (TEXT_EXTENSIONS, false),
2345 (MIXED_CASE_BINARY, true),
2346 ];
2347
2348 for (files, expected_binary) in test_cases {
2349 for file in files {
2350 assert_eq!(
2351 is_likely_binary(Path::new(file)),
2352 expected_binary,
2353 "Binary detection failed for {file}"
2354 );
2355 }
2356 }
2357 }
2358
2359 #[test]
2360 fn test_is_likely_binary_no_extension() {
2361 assert!(!is_likely_binary(Path::new("filename")));
2362 assert!(!is_likely_binary(Path::new("/path/to/file")));
2363 }
2364
2365 #[test]
2366 fn test_is_likely_binary_empty_extension() {
2367 assert!(!is_likely_binary(Path::new("file.")));
2368 }
2369
2370 #[test]
2371 fn test_is_likely_binary_complex_paths() {
2372 assert!(is_likely_binary(Path::new("/complex/path/to/image.png")));
2373 assert!(!is_likely_binary(Path::new("/complex/path/to/source.rs")));
2374 }
2375
2376 #[test]
2377 fn test_is_likely_binary_hidden_files() {
2378 assert!(is_likely_binary(Path::new(".hidden.png")));
2379 assert!(!is_likely_binary(Path::new(".hidden.txt")));
2380 }
2381 }
2382}