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