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