frep_core/
search.rs

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