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