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