frep_core/
replace.rs

1use std::{
2    collections::HashMap,
3    fs::{self, File},
4    io::{BufReader, BufWriter, Write},
5    path::Path,
6};
7use tempfile::NamedTempFile;
8
9use crate::search::{SearchResult, SearchResultWithReplacement, SearchType};
10use crate::{line_reader::BufReadExt, search};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum ReplaceResult {
14    Success,
15    Error(String),
16}
17
18/// NOTE: this should only be called with search results from the same file
19// TODO: enforce the above via types
20pub fn replace_in_file(results: &mut [SearchResultWithReplacement]) -> anyhow::Result<()> {
21    let file_path = match results {
22        [r, ..] => r.search_result.path.clone(),
23        [] => return Ok(()),
24    };
25    debug_assert!(results.iter().all(|r| r.search_result.path == file_path));
26
27    let mut line_map = results
28        .iter_mut()
29        .map(|res| (res.search_result.line_number, res))
30        .collect::<HashMap<_, _>>();
31
32    let file_path = file_path.expect("File path must be present when searching in files");
33    let parent_dir = file_path.parent().unwrap_or(Path::new("."));
34    let temp_output_file = NamedTempFile::new_in(parent_dir)?;
35
36    // Scope the file operations so they're closed before rename
37    {
38        let input = File::open(file_path.clone())?;
39        let reader = BufReader::new(input);
40
41        let output = File::create(temp_output_file.path())?;
42        let mut writer = BufWriter::new(output);
43
44        for (idx, line_result) in reader.lines_with_endings().enumerate() {
45            let line_number = idx + 1; // Ensure line-number is 1-indexed
46            let (mut line, line_ending) = line_result?;
47            if let Some(res) = line_map.get_mut(&line_number) {
48                if line == res.search_result.line.as_bytes() {
49                    line = res.replacement.as_bytes().to_vec();
50                    res.replace_result = Some(ReplaceResult::Success);
51                } else {
52                    res.replace_result = Some(ReplaceResult::Error(
53                        "File changed since last search".to_owned(),
54                    ));
55                }
56            }
57            line.extend(line_ending.as_bytes());
58            writer.write_all(&line)?;
59        }
60
61        writer.flush()?;
62    }
63
64    temp_output_file.persist(file_path)?;
65    Ok(())
66}
67
68const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
69
70fn should_replace_in_memory(path: &Path) -> Result<bool, std::io::Error> {
71    let file_size = fs::metadata(path)?.len();
72    Ok(file_size <= MAX_FILE_SIZE)
73}
74
75/// Performs search and replace operations in a file
76///
77/// This function implements a hybrid approach to file replacements:
78/// 1. For files under the `MAX_FILE_SIZE` threshold, it attempts an in-memory replacement
79/// 2. If the file is large or in-memory replacement fails, it falls back to line-by-line chunked replacement
80///
81/// This approach optimizes for performance while maintaining reasonable memory usage limits.
82///
83/// # Arguments
84///
85/// * `file_path` - Path to the file to process
86/// * `search` - The search pattern (fixed string, regex, or advanced regex)
87/// * `replace` - The replacement string
88///
89/// # Returns
90///
91/// * `Ok(true)` if replacements were made in the file
92/// * `Ok(false)` if no replacements were made (no matches found)
93/// * `Err` if any errors occurred during the operation
94pub fn replace_all_in_file(
95    file_path: &Path,
96    search: &SearchType,
97    replace: &str,
98) -> anyhow::Result<bool> {
99    // Try to read into memory if not too large - if this fails, or if too large, fall back to line-by-line replacement
100    if matches!(should_replace_in_memory(file_path), Ok(true)) {
101        match replace_in_memory(file_path, search, replace) {
102            Ok(replaced) => return Ok(replaced),
103            Err(e) => {
104                log::error!(
105                    "Found error when attempting to replace in memory for file {path_display}: {e}",
106                    path_display = file_path.display(),
107                );
108            }
109        }
110    }
111
112    replace_chunked(file_path, search, replace)
113}
114
115pub fn add_replacement(
116    search_result: SearchResult,
117    search: &SearchType,
118    replace: &str,
119) -> Option<SearchResultWithReplacement> {
120    let replacement = replacement_if_match(&search_result.line, search, replace)?;
121    Some(SearchResultWithReplacement {
122        search_result,
123        replacement,
124        replace_result: None,
125    })
126}
127
128fn replace_chunked(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
129    let search_results = search::search_file(file_path, search)?;
130    if !search_results.is_empty() {
131        let mut replacement_results = search_results
132            .into_iter()
133            .map(|r| {
134                add_replacement(r, search, replace).unwrap_or_else(|| {
135                    panic!("Called add_replacement with non-matching search result")
136                })
137            })
138            .collect::<Vec<_>>();
139        replace_in_file(&mut replacement_results)?;
140        return Ok(true);
141    }
142
143    Ok(false)
144}
145
146fn replace_in_memory(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
147    let content = fs::read_to_string(file_path)?;
148    if let Some(new_content) = replacement_if_match(&content, search, replace) {
149        let parent_dir = file_path.parent().unwrap_or(Path::new("."));
150        let mut temp_file = NamedTempFile::new_in(parent_dir)?;
151        temp_file.write_all(new_content.as_bytes())?;
152        temp_file.persist(file_path)?;
153        Ok(true)
154    } else {
155        Ok(false)
156    }
157}
158
159/// Performs a search and replace operation on a string if the pattern matches
160///
161/// # Arguments
162///
163/// * `line` - The string to search within
164/// * `search` - The search pattern (fixed string, regex, or advanced regex)
165/// * `replace` - The replacement string
166///
167/// # Returns
168///
169/// * `Some(String)` containing the string with replacements if matches were found
170/// * `None` if no matches were found
171pub fn replacement_if_match(line: &str, search: &SearchType, replace: &str) -> Option<String> {
172    if line.is_empty() || search.is_empty() {
173        return None;
174    }
175
176    if search::contains_search(line, search) {
177        let replacement = match search {
178            SearchType::Fixed(fixed_str) => line.replace(fixed_str, replace),
179            SearchType::Pattern(pattern) => pattern.replace_all(line, replace).to_string(),
180            SearchType::PatternAdvanced(pattern) => pattern.replace_all(line, replace).to_string(),
181        };
182        Some(replacement)
183    } else {
184        None
185    }
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct ReplaceStats {
190    pub num_successes: usize,
191    pub errors: Vec<SearchResultWithReplacement>,
192}
193
194pub fn calculate_statistics<I>(results: I) -> ReplaceStats
195where
196    I: IntoIterator<Item = SearchResultWithReplacement>,
197{
198    let mut num_successes = 0;
199    let mut errors = vec![];
200
201    results.into_iter().for_each(|res| {
202        assert!(
203            res.search_result.included,
204            "Expected only included results, found {res:?}"
205        );
206        match &res.replace_result {
207            Some(ReplaceResult::Success) => {
208                num_successes += 1;
209            }
210            None => {
211                let mut res = res.clone();
212                res.replace_result = Some(ReplaceResult::Error(
213                    "Failed to find search result in file".to_owned(),
214                ));
215                errors.push(res);
216            }
217            Some(ReplaceResult::Error(_)) => {
218                errors.push(res.clone());
219            }
220        }
221    });
222
223    ReplaceStats {
224        num_successes,
225        errors,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::line_reader::LineEnding;
233    use crate::search::{SearchResult, SearchType, search_file};
234    use regex::Regex;
235    use std::path::PathBuf;
236    use tempfile::TempDir;
237
238    mod test_helpers {
239        use crate::search::SearchType;
240
241        pub fn create_fixed_search(term: &str) -> SearchType {
242            SearchType::Fixed(term.to_string())
243        }
244    }
245
246    // Helper functions
247    fn create_search_result_with_replacement(
248        path: &str,
249        line_number: usize,
250        line: &str,
251        replacement: &str,
252        included: bool,
253        replace_result: Option<ReplaceResult>,
254    ) -> SearchResultWithReplacement {
255        SearchResultWithReplacement {
256            search_result: SearchResult {
257                path: Some(PathBuf::from(path)),
258                line_number,
259                line: line.to_string(),
260                line_ending: LineEnding::Lf,
261                included,
262            },
263            replacement: replacement.to_string(),
264            replace_result,
265        }
266    }
267
268    fn create_test_file(temp_dir: &TempDir, name: &str, content: &str) -> PathBuf {
269        let file_path = temp_dir.path().join(name);
270        std::fs::write(&file_path, content).unwrap();
271        file_path
272    }
273
274    fn assert_file_content(file_path: &Path, expected_content: &str) {
275        let content = std::fs::read_to_string(file_path).unwrap();
276        assert_eq!(content, expected_content);
277    }
278
279    fn fixed_search(pattern: &str) -> SearchType {
280        SearchType::Fixed(pattern.to_string())
281    }
282
283    fn regex_search(pattern: &str) -> SearchType {
284        SearchType::Pattern(Regex::new(pattern).unwrap())
285    }
286
287    // Tests for replace_in_file
288    #[test]
289    fn test_replace_in_file_success() {
290        let temp_dir = TempDir::new().unwrap();
291        let file_path = create_test_file(
292            &temp_dir,
293            "test.txt",
294            "line 1\nold text\nline 3\nold text\nline 5\n",
295        );
296
297        // Create search results
298        let mut results = vec![
299            create_search_result_with_replacement(
300                file_path.to_str().unwrap(),
301                2,
302                "old text",
303                "new text",
304                true,
305                None,
306            ),
307            create_search_result_with_replacement(
308                file_path.to_str().unwrap(),
309                4,
310                "old text",
311                "new text",
312                true,
313                None,
314            ),
315        ];
316
317        // Perform replacement
318        let result = replace_in_file(&mut results);
319        assert!(result.is_ok());
320
321        // Verify replacements were marked as successful
322        assert_eq!(results.len(), 2);
323        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
324        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
325
326        // Verify file content
327        assert_file_content(&file_path, "line 1\nnew text\nline 3\nnew text\nline 5\n");
328    }
329
330    #[test]
331    fn test_replace_in_file_success_no_final_newline() {
332        let temp_dir = TempDir::new().unwrap();
333        let file_path = create_test_file(
334            &temp_dir,
335            "test.txt",
336            "line 1\nold text\nline 3\nold text\nline 5",
337        );
338
339        // Create search results
340        let mut results = vec![
341            create_search_result_with_replacement(
342                file_path.to_str().unwrap(),
343                2,
344                "old text",
345                "new text",
346                true,
347                None,
348            ),
349            create_search_result_with_replacement(
350                file_path.to_str().unwrap(),
351                4,
352                "old text",
353                "new text",
354                true,
355                None,
356            ),
357        ];
358
359        // Perform replacement
360        let result = replace_in_file(&mut results);
361        assert!(result.is_ok());
362
363        // Verify replacements were marked as successful
364        assert_eq!(results.len(), 2);
365        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
366        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
367
368        // Verify file content
369        let new_content = std::fs::read_to_string(&file_path).unwrap();
370        assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5");
371    }
372
373    #[test]
374    fn test_replace_in_file_success_windows_newlines() {
375        let temp_dir = TempDir::new().unwrap();
376        let file_path = create_test_file(
377            &temp_dir,
378            "test.txt",
379            "line 1\r\nold text\r\nline 3\r\nold text\r\nline 5\r\n",
380        );
381
382        // Create search results
383        let mut results = vec![
384            create_search_result_with_replacement(
385                file_path.to_str().unwrap(),
386                2,
387                "old text",
388                "new text",
389                true,
390                None,
391            ),
392            create_search_result_with_replacement(
393                file_path.to_str().unwrap(),
394                4,
395                "old text",
396                "new text",
397                true,
398                None,
399            ),
400        ];
401
402        // Perform replacement
403        let result = replace_in_file(&mut results);
404        assert!(result.is_ok());
405
406        // Verify replacements were marked as successful
407        assert_eq!(results.len(), 2);
408        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
409        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
410
411        // Verify file content
412        let new_content = std::fs::read_to_string(&file_path).unwrap();
413        assert_eq!(
414            new_content,
415            "line 1\r\nnew text\r\nline 3\r\nnew text\r\nline 5\r\n"
416        );
417    }
418
419    #[test]
420    fn test_replace_in_file_success_mixed_newlines() {
421        let temp_dir = TempDir::new().unwrap();
422        let file_path = create_test_file(
423            &temp_dir,
424            "test.txt",
425            "\n\r\nline 1\nold text\r\nline 3\nline 4\r\nline 5\r\n\n\n",
426        );
427
428        // Create search results
429        let mut results = vec![
430            create_search_result_with_replacement(
431                file_path.to_str().unwrap(),
432                4,
433                "old text",
434                "new text",
435                true,
436                None,
437            ),
438            create_search_result_with_replacement(
439                file_path.to_str().unwrap(),
440                7,
441                "line 5",
442                "updated line 5",
443                true,
444                None,
445            ),
446        ];
447
448        // Perform replacement
449        let result = replace_in_file(&mut results);
450        assert!(result.is_ok());
451
452        // Verify replacements were marked as successful
453        assert_eq!(results.len(), 2);
454        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
455        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
456
457        // Verify file content
458        let new_content = std::fs::read_to_string(&file_path).unwrap();
459        assert_eq!(
460            new_content,
461            "\n\r\nline 1\nnew text\r\nline 3\nline 4\r\nupdated line 5\r\n\n\n"
462        );
463    }
464
465    #[test]
466    fn test_replace_in_file_line_mismatch() {
467        let temp_dir = TempDir::new().unwrap();
468        let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nactual text\nline 3\n");
469
470        // Create search result with mismatching line
471        let mut results = vec![create_search_result_with_replacement(
472            file_path.to_str().unwrap(),
473            2,
474            "expected text",
475            "new text",
476            true,
477            None,
478        )];
479
480        // Perform replacement
481        let result = replace_in_file(&mut results);
482        assert!(result.is_ok());
483
484        // Verify replacement was marked as error
485        assert_eq!(
486            results[0].replace_result,
487            Some(ReplaceResult::Error(
488                "File changed since last search".to_owned()
489            ))
490        );
491
492        // Verify file content is unchanged
493        let new_content = std::fs::read_to_string(&file_path).unwrap();
494        assert_eq!(new_content, "line 1\nactual text\nline 3\n");
495    }
496
497    #[test]
498    fn test_replace_in_file_nonexistent_file() {
499        let mut results = vec![create_search_result_with_replacement(
500            "/nonexistent/path/file.txt",
501            1,
502            "old",
503            "new",
504            true,
505            None,
506        )];
507
508        let result = replace_in_file(&mut results);
509        assert!(result.is_err());
510    }
511
512    #[test]
513    fn test_replace_directory_errors() {
514        let mut results = vec![create_search_result_with_replacement(
515            "/", 0, "foo", "bar", true, None,
516        )];
517
518        let result = replace_in_file(&mut results);
519        assert!(result.is_err());
520    }
521
522    // Tests for replace_in_memory
523    #[test]
524    fn test_replace_in_memory() {
525        let temp_dir = TempDir::new().unwrap();
526
527        // Test with fixed string
528        let file_path = create_test_file(
529            &temp_dir,
530            "test.txt",
531            "This is a test.\nIt contains search_term that should be replaced.\nMultiple lines with search_term here.",
532        );
533
534        let result = replace_in_memory(&file_path, &fixed_search("search_term"), "replacement");
535        assert!(result.is_ok());
536        assert!(result.unwrap()); // Should return true for modifications
537
538        assert_file_content(
539            &file_path,
540            "This is a test.\nIt contains replacement that should be replaced.\nMultiple lines with replacement here.",
541        );
542
543        // Test with regex pattern
544        let regex_path = create_test_file(
545            &temp_dir,
546            "regex_test.txt",
547            "Number: 123, Code: 456, ID: 789",
548        );
549
550        let result = replace_in_memory(&regex_path, &regex_search(r"\d{3}"), "XXX");
551        assert!(result.is_ok());
552        assert!(result.unwrap());
553
554        assert_file_content(&regex_path, "Number: XXX, Code: XXX, ID: XXX");
555    }
556
557    #[test]
558    fn test_replace_in_memory_no_match() {
559        let temp_dir = TempDir::new().unwrap();
560        let file_path = create_test_file(
561            &temp_dir,
562            "no_match.txt",
563            "This is a test file with no matches.",
564        );
565
566        let result = replace_in_memory(&file_path, &fixed_search("nonexistent"), "replacement");
567        assert!(result.is_ok());
568        assert!(!result.unwrap()); // Should return false for no modifications
569
570        // Verify file content unchanged
571        assert_file_content(&file_path, "This is a test file with no matches.");
572    }
573
574    #[test]
575    fn test_replace_in_memory_empty_file() {
576        let temp_dir = TempDir::new().unwrap();
577        let file_path = create_test_file(&temp_dir, "empty.txt", "");
578
579        let result = replace_in_memory(&file_path, &fixed_search("anything"), "replacement");
580        assert!(result.is_ok());
581        assert!(!result.unwrap());
582
583        // Verify file still empty
584        assert_file_content(&file_path, "");
585    }
586
587    #[test]
588    fn test_replace_in_memory_nonexistent_file() {
589        let result = replace_in_memory(
590            Path::new("/nonexistent/path/file.txt"),
591            &fixed_search("test"),
592            "replacement",
593        );
594        assert!(result.is_err());
595    }
596
597    // Tests for replace_chunked
598    #[test]
599    fn test_replace_chunked() {
600        let temp_dir = TempDir::new().unwrap();
601
602        // Test with fixed string
603        let file_path = create_test_file(
604            &temp_dir,
605            "test.txt",
606            "This is line one.\nThis contains search_pattern to replace.\nAnother line with search_pattern here.\nFinal line.",
607        );
608
609        let result = replace_chunked(&file_path, &fixed_search("search_pattern"), "replacement");
610        assert!(result.is_ok());
611        assert!(result.unwrap()); // Check that replacement happened
612
613        assert_file_content(
614            &file_path,
615            "This is line one.\nThis contains replacement to replace.\nAnother line with replacement here.\nFinal line.",
616        );
617
618        // Test with regex pattern
619        let regex_path = create_test_file(
620            &temp_dir,
621            "regex.txt",
622            "Line with numbers: 123 and 456.\nAnother line with 789.",
623        );
624
625        let result = replace_chunked(&regex_path, &regex_search(r"\d{3}"), "XXX");
626        assert!(result.is_ok());
627        assert!(result.unwrap());
628
629        assert_file_content(
630            &regex_path,
631            "Line with numbers: XXX and XXX.\nAnother line with XXX.",
632        );
633    }
634
635    #[test]
636    fn test_replace_chunked_no_match() {
637        let temp_dir = TempDir::new().unwrap();
638        let file_path = create_test_file(
639            &temp_dir,
640            "test.txt",
641            "This is a test file with no matching patterns.",
642        );
643
644        let result = replace_chunked(&file_path, &fixed_search("nonexistent"), "replacement");
645        assert!(result.is_ok());
646        assert!(!result.unwrap());
647
648        // Verify file content unchanged
649        assert_file_content(&file_path, "This is a test file with no matching patterns.");
650    }
651
652    #[test]
653    fn test_replace_chunked_empty_file() {
654        let temp_dir = TempDir::new().unwrap();
655        let file_path = create_test_file(&temp_dir, "empty.txt", "");
656
657        let result = replace_chunked(&file_path, &fixed_search("anything"), "replacement");
658        assert!(result.is_ok());
659        assert!(!result.unwrap());
660
661        // Verify file still empty
662        assert_file_content(&file_path, "");
663    }
664
665    #[test]
666    fn test_replace_chunked_nonexistent_file() {
667        let result = replace_chunked(
668            Path::new("/nonexistent/path/file.txt"),
669            &fixed_search("test"),
670            "replacement",
671        );
672        assert!(result.is_err());
673    }
674
675    // Tests for replace_all_in_file
676    #[test]
677    fn test_replace_all_in_file() {
678        let temp_dir = TempDir::new().unwrap();
679        let file_path = create_test_file(
680            &temp_dir,
681            "test.txt",
682            "This is a test file.\nIt has some content to replace.\nThe word replace should be replaced.",
683        );
684
685        let result = replace_all_in_file(&file_path, &fixed_search("replace"), "modify");
686        assert!(result.is_ok());
687        assert!(result.unwrap());
688
689        assert_file_content(
690            &file_path,
691            "This is a test file.\nIt has some content to modify.\nThe word modify should be modifyd.",
692        );
693    }
694
695    #[test]
696    fn test_unicode_in_file() {
697        let mut temp_file = NamedTempFile::new().unwrap();
698        writeln!(temp_file, "Line with Greek: αβγδε").unwrap();
699        write!(temp_file, "Line with Emoji: 😀 🚀 🌍\r\n").unwrap();
700        write!(temp_file, "Line with Arabic: مرحبا بالعالم").unwrap();
701        temp_file.flush().unwrap();
702
703        let search = SearchType::Pattern(Regex::new(r"\p{Greek}+").unwrap());
704        let replacement = "GREEK";
705        let results = search_file(temp_file.path(), &search)
706            .unwrap()
707            .into_iter()
708            .filter_map(|r| add_replacement(r, &search, replacement))
709            .collect::<Vec<_>>();
710
711        assert_eq!(results.len(), 1);
712        assert_eq!(results[0].replacement, "Line with Greek: GREEK");
713
714        let search = SearchType::Pattern(Regex::new(r"🚀").unwrap());
715        let replacement = "ROCKET";
716        let results = search_file(temp_file.path(), &search)
717            .unwrap()
718            .into_iter()
719            .filter_map(|r| add_replacement(r, &search, replacement))
720            .collect::<Vec<_>>();
721
722        assert_eq!(results.len(), 1);
723        assert_eq!(results[0].replacement, "Line with Emoji: 😀 ROCKET 🌍");
724        assert_eq!(results[0].search_result.line_ending, LineEnding::CrLf);
725    }
726
727    mod search_file_tests {
728        use super::*;
729        use fancy_regex::Regex as FancyRegex;
730        use regex::Regex;
731        use std::io::Write;
732        use tempfile::NamedTempFile;
733
734        #[test]
735        fn test_search_file_simple_match() {
736            let mut temp_file = NamedTempFile::new().unwrap();
737            writeln!(temp_file, "line 1").unwrap();
738            writeln!(temp_file, "search target").unwrap();
739            writeln!(temp_file, "line 3").unwrap();
740            temp_file.flush().unwrap();
741
742            let search = test_helpers::create_fixed_search("search");
743            let replacement = "replace";
744            let results = search_file(temp_file.path(), &search)
745                .unwrap()
746                .into_iter()
747                .filter_map(|r| add_replacement(r, &search, replacement))
748                .collect::<Vec<_>>();
749
750            assert_eq!(results.len(), 1);
751            assert_eq!(results[0].search_result.line_number, 2);
752            assert_eq!(results[0].search_result.line, "search target");
753            assert_eq!(results[0].replacement, "replace target");
754            assert!(results[0].search_result.included);
755        }
756
757        #[test]
758        fn test_search_file_multiple_matches() {
759            let mut temp_file = NamedTempFile::new().unwrap();
760            writeln!(temp_file, "test line 1").unwrap();
761            writeln!(temp_file, "test line 2").unwrap();
762            writeln!(temp_file, "no match here").unwrap();
763            writeln!(temp_file, "test line 4").unwrap();
764            temp_file.flush().unwrap();
765
766            let search = test_helpers::create_fixed_search("test");
767            let replacement = "replaced";
768            let results = search_file(temp_file.path(), &search)
769                .unwrap()
770                .into_iter()
771                .filter_map(|r| add_replacement(r, &search, replacement))
772                .collect::<Vec<_>>();
773
774            assert_eq!(results.len(), 3);
775            assert_eq!(results[0].search_result.line_number, 1);
776            assert_eq!(results[0].replacement, "replaced line 1");
777            assert_eq!(results[1].search_result.line_number, 2);
778            assert_eq!(results[1].replacement, "replaced line 2");
779            assert_eq!(results[2].search_result.line_number, 4);
780            assert_eq!(results[2].replacement, "replaced line 4");
781        }
782
783        #[test]
784        fn test_search_file_no_matches() {
785            let mut temp_file = NamedTempFile::new().unwrap();
786            writeln!(temp_file, "line 1").unwrap();
787            writeln!(temp_file, "line 2").unwrap();
788            writeln!(temp_file, "line 3").unwrap();
789            temp_file.flush().unwrap();
790
791            let search = SearchType::Fixed("nonexistent".to_string());
792            let replacement = "replace";
793            let results = search_file(temp_file.path(), &search)
794                .unwrap()
795                .into_iter()
796                .filter_map(|r| add_replacement(r, &search, replacement))
797                .collect::<Vec<_>>();
798
799            assert_eq!(results.len(), 0);
800        }
801
802        #[test]
803        fn test_search_file_regex_pattern() {
804            let mut temp_file = NamedTempFile::new().unwrap();
805            writeln!(temp_file, "number: 123").unwrap();
806            writeln!(temp_file, "text without numbers").unwrap();
807            writeln!(temp_file, "another number: 456").unwrap();
808            temp_file.flush().unwrap();
809
810            let search = SearchType::Pattern(Regex::new(r"\d+").unwrap());
811            let replacement = "XXX";
812            let results = search_file(temp_file.path(), &search)
813                .unwrap()
814                .into_iter()
815                .filter_map(|r| add_replacement(r, &search, replacement))
816                .collect::<Vec<_>>();
817
818            assert_eq!(results.len(), 2);
819            assert_eq!(results[0].replacement, "number: XXX");
820            assert_eq!(results[1].replacement, "another number: XXX");
821        }
822
823        #[test]
824        fn test_search_file_advanced_regex_pattern() {
825            let mut temp_file = NamedTempFile::new().unwrap();
826            writeln!(temp_file, "123abc456").unwrap();
827            writeln!(temp_file, "abc").unwrap();
828            writeln!(temp_file, "789xyz123").unwrap();
829            writeln!(temp_file, "no match").unwrap();
830            temp_file.flush().unwrap();
831
832            // Positive lookbehind and lookahead
833            let search =
834                SearchType::PatternAdvanced(FancyRegex::new(r"(?<=\d{3})abc(?=\d{3})").unwrap());
835            let replacement = "REPLACED";
836            let results = search_file(temp_file.path(), &search)
837                .unwrap()
838                .into_iter()
839                .filter_map(|r| add_replacement(r, &search, replacement))
840                .collect::<Vec<_>>();
841
842            assert_eq!(results.len(), 1);
843            assert_eq!(results[0].replacement, "123REPLACED456");
844            assert_eq!(results[0].search_result.line_number, 1);
845        }
846
847        #[test]
848        fn test_search_file_empty_search() {
849            let mut temp_file = NamedTempFile::new().unwrap();
850            writeln!(temp_file, "some content").unwrap();
851            temp_file.flush().unwrap();
852
853            let search = SearchType::Fixed("".to_string());
854            let replacement = "replace";
855            let results = search_file(temp_file.path(), &search)
856                .unwrap()
857                .into_iter()
858                .filter_map(|r| add_replacement(r, &search, replacement))
859                .collect::<Vec<_>>();
860
861            assert_eq!(results.len(), 0);
862        }
863
864        #[test]
865        fn test_search_file_preserves_line_endings() {
866            let mut temp_file = NamedTempFile::new().unwrap();
867            write!(temp_file, "line1\nline2\r\nline3").unwrap();
868            temp_file.flush().unwrap();
869
870            let search = SearchType::Fixed("line".to_string());
871            let replacement = "X";
872            let results = search_file(temp_file.path(), &search)
873                .unwrap()
874                .into_iter()
875                .filter_map(|r| add_replacement(r, &search, replacement))
876                .collect::<Vec<_>>();
877
878            assert_eq!(results.len(), 3);
879            assert_eq!(results[0].search_result.line_ending, LineEnding::Lf);
880            assert_eq!(results[1].search_result.line_ending, LineEnding::CrLf);
881            assert_eq!(results[2].search_result.line_ending, LineEnding::None);
882        }
883
884        #[test]
885        fn test_search_file_nonexistent() {
886            let nonexistent_path = PathBuf::from("/this/file/does/not/exist.txt");
887            let search = test_helpers::create_fixed_search("test");
888            let results = search_file(&nonexistent_path, &search);
889            assert!(results.is_err());
890        }
891
892        #[test]
893        fn test_search_file_unicode_content() {
894            let mut temp_file = NamedTempFile::new().unwrap();
895            writeln!(temp_file, "Hello 世界!").unwrap();
896            writeln!(temp_file, "Здравствуй мир!").unwrap();
897            writeln!(temp_file, "🚀 Rocket").unwrap();
898            temp_file.flush().unwrap();
899
900            let search = SearchType::Fixed("世界".to_string());
901            let replacement = "World";
902            let results = search_file(temp_file.path(), &search)
903                .unwrap()
904                .into_iter()
905                .filter_map(|r| add_replacement(r, &search, replacement))
906                .collect::<Vec<_>>();
907
908            assert_eq!(results.len(), 1);
909            assert_eq!(results[0].replacement, "Hello World!");
910        }
911
912        #[test]
913        fn test_search_file_with_binary_content() {
914            let mut temp_file = NamedTempFile::new().unwrap();
915            // Write some binary data (null bytes and other control characters)
916            let binary_data = [0x00, 0x01, 0x02, 0xFF, 0xFE];
917            temp_file.write_all(&binary_data).unwrap();
918            temp_file.flush().unwrap();
919
920            let search = test_helpers::create_fixed_search("test");
921            let replacement = "replace";
922            let results = search_file(temp_file.path(), &search)
923                .unwrap()
924                .into_iter()
925                .filter_map(|r| add_replacement(r, &search, replacement))
926                .collect::<Vec<_>>();
927
928            assert_eq!(results.len(), 0);
929        }
930
931        #[test]
932        fn test_search_file_large_content() {
933            let mut temp_file = NamedTempFile::new().unwrap();
934
935            // Write a large file with search targets scattered throughout
936            for i in 0..1000 {
937                if i % 100 == 0 {
938                    writeln!(temp_file, "target line {i}").unwrap();
939                } else {
940                    writeln!(temp_file, "normal line {i}").unwrap();
941                }
942            }
943            temp_file.flush().unwrap();
944
945            let search = SearchType::Fixed("target".to_string());
946            let replacement = "found";
947            let results = search_file(temp_file.path(), &search)
948                .unwrap()
949                .into_iter()
950                .filter_map(|r| add_replacement(r, &search, replacement))
951                .collect::<Vec<_>>();
952
953            assert_eq!(results.len(), 10); // Lines 0, 100, 200, ..., 900
954            assert_eq!(results[0].search_result.line_number, 1); // 1-indexed
955            assert_eq!(results[1].search_result.line_number, 101);
956            assert_eq!(results[9].search_result.line_number, 901);
957        }
958    }
959
960    mod replace_if_match_tests {
961        use crate::validation::SearchConfig;
962
963        use super::*;
964
965        mod test_helpers {
966            use crate::{
967                search::ParsedSearchConfig,
968                validation::{
969                    SearchConfig, SimpleErrorHandler, ValidationResult,
970                    validate_search_configuration,
971                },
972            };
973
974            pub fn must_parse_search_config(search_config: SearchConfig<'_>) -> ParsedSearchConfig {
975                let mut error_handler = SimpleErrorHandler::new();
976                let (search_config, _dir_config) =
977                    match validate_search_configuration(search_config, None, &mut error_handler)
978                        .unwrap()
979                    {
980                        ValidationResult::Success(search_config) => search_config,
981                        ValidationResult::ValidationErrors => {
982                            panic!("{}", error_handler.errors_str().unwrap());
983                        }
984                    };
985                search_config
986            }
987        }
988
989        mod fixed_string_tests {
990            use super::*;
991
992            mod whole_word_true_match_case_true {
993
994                use super::*;
995
996                #[test]
997                fn test_basic_replacement() {
998                    let search_config = SearchConfig {
999                        search_text: "world",
1000                        fixed_strings: true,
1001                        match_whole_word: true,
1002                        match_case: true,
1003                        replacement_text: "earth",
1004                        advanced_regex: false,
1005                    };
1006                    let parsed = test_helpers::must_parse_search_config(search_config);
1007
1008                    assert_eq!(
1009                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1010                        Some("hello earth".to_string())
1011                    );
1012                }
1013
1014                #[test]
1015                fn test_case_sensitivity() {
1016                    let search_config = SearchConfig {
1017                        search_text: "world",
1018                        fixed_strings: true,
1019                        match_whole_word: true,
1020                        match_case: true,
1021                        replacement_text: "earth",
1022                        advanced_regex: false,
1023                    };
1024                    let parsed = test_helpers::must_parse_search_config(search_config);
1025
1026                    assert_eq!(
1027                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1028                        None
1029                    );
1030                }
1031
1032                #[test]
1033                fn test_word_boundaries() {
1034                    let search_config = SearchConfig {
1035                        search_text: "world",
1036                        fixed_strings: true,
1037                        match_whole_word: true,
1038                        match_case: true,
1039                        replacement_text: "earth",
1040                        advanced_regex: false,
1041                    };
1042                    let parsed = test_helpers::must_parse_search_config(search_config);
1043
1044                    assert_eq!(
1045                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1046                        None
1047                    );
1048                }
1049            }
1050
1051            mod whole_word_true_match_case_false {
1052                use super::*;
1053
1054                #[test]
1055                fn test_basic_replacement() {
1056                    let search_config = SearchConfig {
1057                        search_text: "world",
1058                        fixed_strings: true,
1059                        match_whole_word: true,
1060                        match_case: false,
1061                        replacement_text: "earth",
1062                        advanced_regex: false,
1063                    };
1064                    let parsed = test_helpers::must_parse_search_config(search_config);
1065
1066                    assert_eq!(
1067                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1068                        Some("hello earth".to_string())
1069                    );
1070                }
1071
1072                #[test]
1073                fn test_case_insensitivity() {
1074                    let search_config = SearchConfig {
1075                        search_text: "world",
1076                        fixed_strings: true,
1077                        match_whole_word: true,
1078                        match_case: false,
1079                        replacement_text: "earth",
1080                        advanced_regex: false,
1081                    };
1082                    let parsed = test_helpers::must_parse_search_config(search_config);
1083
1084                    assert_eq!(
1085                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1086                        Some("hello earth".to_string())
1087                    );
1088                }
1089
1090                #[test]
1091                fn test_word_boundaries() {
1092                    let search_config = SearchConfig {
1093                        search_text: "world",
1094                        fixed_strings: true,
1095                        match_whole_word: true,
1096                        match_case: false,
1097                        replacement_text: "earth",
1098                        advanced_regex: false,
1099                    };
1100                    let parsed = test_helpers::must_parse_search_config(search_config);
1101
1102                    assert_eq!(
1103                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1104                        None
1105                    );
1106                }
1107
1108                #[test]
1109                fn test_unicode() {
1110                    let search_config = SearchConfig {
1111                        search_text: "café",
1112                        fixed_strings: true,
1113                        match_whole_word: true,
1114                        match_case: false,
1115                        replacement_text: "restaurant",
1116                        advanced_regex: false,
1117                    };
1118                    let parsed = test_helpers::must_parse_search_config(search_config);
1119
1120                    assert_eq!(
1121                        replacement_if_match("Hello CAFÉ table", &parsed.search, &parsed.replace),
1122                        Some("Hello restaurant table".to_string())
1123                    );
1124                }
1125            }
1126
1127            mod whole_word_false_match_case_true {
1128                use super::*;
1129
1130                #[test]
1131                fn test_basic_replacement() {
1132                    let search_config = SearchConfig {
1133                        search_text: "world",
1134                        fixed_strings: true,
1135                        match_whole_word: false,
1136                        match_case: true,
1137                        replacement_text: "earth",
1138                        advanced_regex: false,
1139                    };
1140                    let parsed = test_helpers::must_parse_search_config(search_config);
1141
1142                    assert_eq!(
1143                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1144                        Some("hello earth".to_string())
1145                    );
1146                }
1147
1148                #[test]
1149                fn test_case_sensitivity() {
1150                    let search_config = SearchConfig {
1151                        search_text: "world",
1152                        fixed_strings: true,
1153                        match_whole_word: false,
1154                        match_case: true,
1155                        replacement_text: "earth",
1156                        advanced_regex: false,
1157                    };
1158                    let parsed = test_helpers::must_parse_search_config(search_config);
1159
1160                    assert_eq!(
1161                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1162                        None
1163                    );
1164                }
1165
1166                #[test]
1167                fn test_substring_matches() {
1168                    let search_config = SearchConfig {
1169                        search_text: "world",
1170                        fixed_strings: true,
1171                        match_whole_word: false,
1172                        match_case: true,
1173                        replacement_text: "earth",
1174                        advanced_regex: false,
1175                    };
1176                    let parsed = test_helpers::must_parse_search_config(search_config);
1177
1178                    assert_eq!(
1179                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1180                        Some("earthwide".to_string())
1181                    );
1182                }
1183            }
1184
1185            mod whole_word_false_match_case_false {
1186                use super::*;
1187
1188                #[test]
1189                fn test_basic_replacement() {
1190                    let search_config = SearchConfig {
1191                        search_text: "world",
1192                        fixed_strings: true,
1193                        match_whole_word: false,
1194                        match_case: false,
1195                        replacement_text: "earth",
1196                        advanced_regex: false,
1197                    };
1198                    let parsed = test_helpers::must_parse_search_config(search_config);
1199
1200                    assert_eq!(
1201                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1202                        Some("hello earth".to_string())
1203                    );
1204                }
1205
1206                #[test]
1207                fn test_case_insensitivity() {
1208                    let search_config = SearchConfig {
1209                        search_text: "world",
1210                        fixed_strings: true,
1211                        match_whole_word: false,
1212                        match_case: false,
1213                        replacement_text: "earth",
1214                        advanced_regex: false,
1215                    };
1216                    let parsed = test_helpers::must_parse_search_config(search_config);
1217
1218                    assert_eq!(
1219                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1220                        Some("hello earth".to_string())
1221                    );
1222                }
1223
1224                #[test]
1225                fn test_substring_matches() {
1226                    let search_config = SearchConfig {
1227                        search_text: "world",
1228                        fixed_strings: true,
1229                        match_whole_word: false,
1230                        match_case: false,
1231                        replacement_text: "earth",
1232                        advanced_regex: false,
1233                    };
1234                    let parsed = test_helpers::must_parse_search_config(search_config);
1235
1236                    assert_eq!(
1237                        replacement_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
1238                        Some("earthWIDE".to_string())
1239                    );
1240                }
1241            }
1242        }
1243
1244        mod regex_pattern_tests {
1245            use super::*;
1246
1247            mod whole_word_true_match_case_true {
1248                use crate::validation::SearchConfig;
1249
1250                use super::*;
1251
1252                #[test]
1253                fn test_basic_regex() {
1254                    let re_str = r"w\w+d";
1255                    let search_config = SearchConfig {
1256                        search_text: re_str,
1257                        fixed_strings: false,
1258                        match_whole_word: true,
1259                        match_case: true,
1260                        replacement_text: "earth",
1261                        advanced_regex: false,
1262                    };
1263                    let parsed = test_helpers::must_parse_search_config(search_config);
1264
1265                    assert_eq!(
1266                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1267                        Some("hello earth".to_string())
1268                    );
1269                }
1270
1271                #[test]
1272                fn test_case_sensitivity() {
1273                    let re_str = r"world";
1274                    let search_config = SearchConfig {
1275                        search_text: re_str,
1276                        fixed_strings: false,
1277                        match_whole_word: true,
1278                        match_case: true,
1279                        replacement_text: "earth",
1280                        advanced_regex: false,
1281                    };
1282                    let parsed = test_helpers::must_parse_search_config(search_config);
1283
1284                    assert_eq!(
1285                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1286                        None
1287                    );
1288                }
1289
1290                #[test]
1291                fn test_word_boundaries() {
1292                    let re_str = r"world";
1293                    let search_config = SearchConfig {
1294                        search_text: re_str,
1295                        fixed_strings: false,
1296                        match_whole_word: true,
1297                        match_case: true,
1298                        replacement_text: "earth",
1299                        advanced_regex: false,
1300                    };
1301                    let parsed = test_helpers::must_parse_search_config(search_config);
1302
1303                    assert_eq!(
1304                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1305                        None
1306                    );
1307                }
1308            }
1309
1310            mod whole_word_true_match_case_false {
1311                use super::*;
1312
1313                #[test]
1314                fn test_basic_regex() {
1315                    let re_str = r"w\w+d";
1316                    let search_config = SearchConfig {
1317                        search_text: re_str,
1318                        fixed_strings: false,
1319                        match_whole_word: true,
1320                        match_case: false,
1321                        replacement_text: "earth",
1322                        advanced_regex: false,
1323                    };
1324                    let parsed = test_helpers::must_parse_search_config(search_config);
1325
1326                    assert_eq!(
1327                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1328                        Some("hello earth".to_string())
1329                    );
1330                }
1331
1332                #[test]
1333                fn test_word_boundaries() {
1334                    let re_str = r"world";
1335                    let search_config = SearchConfig {
1336                        search_text: re_str,
1337                        fixed_strings: false,
1338                        match_whole_word: true,
1339                        match_case: false,
1340                        replacement_text: "earth",
1341                        advanced_regex: false,
1342                    };
1343                    let parsed = test_helpers::must_parse_search_config(search_config);
1344
1345                    assert_eq!(
1346                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1347                        None
1348                    );
1349                }
1350
1351                #[test]
1352                fn test_special_characters() {
1353                    let re_str = r"\d+";
1354                    let search_config = SearchConfig {
1355                        search_text: re_str,
1356                        fixed_strings: false,
1357                        match_whole_word: true,
1358                        match_case: false,
1359                        replacement_text: "NUM",
1360                        advanced_regex: false,
1361                    };
1362                    let parsed = test_helpers::must_parse_search_config(search_config);
1363
1364                    assert_eq!(
1365                        replacement_if_match("test 123 number", &parsed.search, &parsed.replace),
1366                        Some("test NUM number".to_string())
1367                    );
1368                }
1369
1370                #[test]
1371                fn test_unicode_word_boundaries() {
1372                    let re_str = r"\b\p{Script=Han}{2}\b";
1373                    let search_config = SearchConfig {
1374                        search_text: re_str,
1375                        fixed_strings: false,
1376                        match_whole_word: true,
1377                        match_case: false,
1378                        replacement_text: "XX",
1379                        advanced_regex: false,
1380                    };
1381                    let parsed = test_helpers::must_parse_search_config(search_config);
1382
1383                    assert!(
1384                        replacement_if_match("Text 世界 more", &parsed.search, &parsed.replace)
1385                            .is_some()
1386                    );
1387                    assert!(replacement_if_match("Text世界more", &parsed.search, "XX").is_none());
1388                }
1389            }
1390
1391            mod whole_word_false_match_case_true {
1392                use super::*;
1393
1394                #[test]
1395                fn test_basic_regex() {
1396                    let re_str = r"w\w+d";
1397                    let search_config = SearchConfig {
1398                        search_text: re_str,
1399                        fixed_strings: false,
1400                        match_whole_word: false,
1401                        match_case: true,
1402                        replacement_text: "earth",
1403                        advanced_regex: false,
1404                    };
1405                    let parsed = test_helpers::must_parse_search_config(search_config);
1406
1407                    assert_eq!(
1408                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1409                        Some("hello earth".to_string())
1410                    );
1411                }
1412
1413                #[test]
1414                fn test_case_sensitivity() {
1415                    let re_str = r"world";
1416                    let search_config = SearchConfig {
1417                        search_text: re_str,
1418                        fixed_strings: false,
1419                        match_whole_word: false,
1420                        match_case: true,
1421                        replacement_text: "earth",
1422                        advanced_regex: false,
1423                    };
1424                    let parsed = test_helpers::must_parse_search_config(search_config);
1425
1426                    assert_eq!(
1427                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1428                        None
1429                    );
1430                }
1431
1432                #[test]
1433                fn test_substring_matches() {
1434                    let re_str = r"world";
1435                    let search_config = SearchConfig {
1436                        search_text: re_str,
1437                        fixed_strings: false,
1438                        match_whole_word: false,
1439                        match_case: true,
1440                        replacement_text: "earth",
1441                        advanced_regex: false,
1442                    };
1443                    let parsed = test_helpers::must_parse_search_config(search_config);
1444
1445                    assert_eq!(
1446                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1447                        Some("earthwide".to_string())
1448                    );
1449                }
1450            }
1451
1452            mod whole_word_false_match_case_false {
1453                use super::*;
1454
1455                #[test]
1456                fn test_basic_regex() {
1457                    let re_str = r"w\w+d";
1458                    let search_config = SearchConfig {
1459                        search_text: re_str,
1460                        fixed_strings: false,
1461                        match_whole_word: false,
1462                        match_case: false,
1463                        replacement_text: "earth",
1464                        advanced_regex: false,
1465                    };
1466                    let parsed = test_helpers::must_parse_search_config(search_config);
1467
1468                    assert_eq!(
1469                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1470                        Some("hello earth".to_string())
1471                    );
1472                }
1473
1474                #[test]
1475                fn test_substring_matches() {
1476                    let re_str = r"world";
1477                    let search_config = SearchConfig {
1478                        search_text: re_str,
1479                        fixed_strings: false,
1480                        match_whole_word: false,
1481                        match_case: false,
1482                        replacement_text: "earth",
1483                        advanced_regex: false,
1484                    };
1485                    let parsed = test_helpers::must_parse_search_config(search_config);
1486
1487                    assert_eq!(
1488                        replacement_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
1489                        Some("earthWIDE".to_string())
1490                    );
1491                }
1492
1493                #[test]
1494                fn test_complex_pattern() {
1495                    let re_str = r"\d{3}-\d{2}-\d{4}";
1496                    let search_config = SearchConfig {
1497                        search_text: re_str,
1498                        fixed_strings: false,
1499                        match_whole_word: false,
1500                        match_case: false,
1501                        replacement_text: "XXX-XX-XXXX",
1502                        advanced_regex: false,
1503                    };
1504                    let parsed = test_helpers::must_parse_search_config(search_config);
1505
1506                    assert_eq!(
1507                        replacement_if_match("SSN: 123-45-6789", &parsed.search, &parsed.replace),
1508                        Some("SSN: XXX-XX-XXXX".to_string())
1509                    );
1510                }
1511            }
1512        }
1513
1514        mod fancy_regex_pattern_tests {
1515            use super::*;
1516
1517            mod whole_word_true_match_case_true {
1518
1519                use super::*;
1520
1521                #[test]
1522                fn test_lookbehind() {
1523                    let re_str = r"(?<=@)\w+";
1524                    let search_config = SearchConfig {
1525                        search_text: re_str,
1526                        match_whole_word: true,
1527                        fixed_strings: false,
1528                        advanced_regex: true,
1529                        match_case: true,
1530                        replacement_text: "domain",
1531                    };
1532                    let parsed = test_helpers::must_parse_search_config(search_config);
1533
1534                    assert_eq!(
1535                        replacement_if_match(
1536                            "email: user@example.com",
1537                            &parsed.search,
1538                            &parsed.replace
1539                        ),
1540                        Some("email: user@domain.com".to_string())
1541                    );
1542                }
1543
1544                #[test]
1545                fn test_lookahead() {
1546                    let re_str = r"\w+(?=\.\w+$)";
1547                    let search_config = SearchConfig {
1548                        search_text: re_str,
1549                        match_whole_word: true,
1550                        fixed_strings: false,
1551                        advanced_regex: true,
1552                        match_case: true,
1553                        replacement_text: "report",
1554                    };
1555                    let parsed = test_helpers::must_parse_search_config(search_config);
1556
1557                    assert_eq!(
1558                        replacement_if_match("file: document.pdf", &parsed.search, &parsed.replace),
1559                        Some("file: report.pdf".to_string())
1560                    );
1561                }
1562
1563                #[test]
1564                fn test_case_sensitivity() {
1565                    let re_str = r"world";
1566                    let search_config = SearchConfig {
1567                        search_text: re_str,
1568                        match_whole_word: true,
1569                        fixed_strings: false,
1570                        advanced_regex: true,
1571                        match_case: true,
1572                        replacement_text: "earth",
1573                    };
1574                    let parsed = test_helpers::must_parse_search_config(search_config);
1575
1576                    assert_eq!(
1577                        replacement_if_match("hello WORLD", &parsed.search, &parsed.replace),
1578                        None
1579                    );
1580                }
1581            }
1582
1583            mod whole_word_true_match_case_false {
1584                use super::*;
1585
1586                #[test]
1587                fn test_lookbehind_case_insensitive() {
1588                    let re_str = r"(?<=@)\w+";
1589                    let search_config = SearchConfig {
1590                        search_text: re_str,
1591                        match_whole_word: true,
1592                        fixed_strings: false,
1593                        advanced_regex: true,
1594                        match_case: false,
1595                        replacement_text: "domain",
1596                    };
1597                    let parsed = test_helpers::must_parse_search_config(search_config);
1598
1599                    assert_eq!(
1600                        replacement_if_match(
1601                            "email: user@EXAMPLE.com",
1602                            &parsed.search,
1603                            &parsed.replace
1604                        ),
1605                        Some("email: user@domain.com".to_string())
1606                    );
1607                }
1608
1609                #[test]
1610                fn test_word_boundaries() {
1611                    let re_str = r"world";
1612                    let search_config = SearchConfig {
1613                        search_text: re_str,
1614                        match_whole_word: true,
1615                        fixed_strings: false,
1616                        advanced_regex: true,
1617                        match_case: false,
1618                        replacement_text: "earth",
1619                    };
1620                    let parsed = test_helpers::must_parse_search_config(search_config);
1621
1622                    assert_eq!(
1623                        replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1624                        None
1625                    );
1626                }
1627            }
1628
1629            mod whole_word_false_match_case_true {
1630                use super::*;
1631
1632                #[test]
1633                fn test_complex_pattern() {
1634                    let re_str = r"(?<=\d{4}-\d{2}-\d{2}T)\d{2}:\d{2}";
1635                    let search_config = SearchConfig {
1636                        search_text: re_str,
1637                        match_whole_word: false,
1638                        fixed_strings: false,
1639                        advanced_regex: true,
1640                        match_case: true,
1641                        replacement_text: "XX:XX",
1642                    };
1643                    let parsed = test_helpers::must_parse_search_config(search_config);
1644
1645                    assert_eq!(
1646                        replacement_if_match(
1647                            "Timestamp: 2023-01-15T14:30:00Z",
1648                            &parsed.search,
1649                            &parsed.replace
1650                        ),
1651                        Some("Timestamp: 2023-01-15TXX:XX:00Z".to_string())
1652                    );
1653                }
1654
1655                #[test]
1656                fn test_case_sensitivity() {
1657                    let re_str = r"WORLD";
1658                    let search_config = SearchConfig {
1659                        search_text: re_str,
1660                        match_whole_word: false,
1661                        fixed_strings: false,
1662                        advanced_regex: true,
1663                        match_case: true,
1664                        replacement_text: "earth",
1665                    };
1666                    let parsed = test_helpers::must_parse_search_config(search_config);
1667
1668                    assert_eq!(
1669                        replacement_if_match("hello world", &parsed.search, &parsed.replace),
1670                        None
1671                    );
1672                }
1673            }
1674
1675            mod whole_word_false_match_case_false {
1676                use super::*;
1677
1678                #[test]
1679                fn test_complex_pattern_case_insensitive() {
1680                    let re_str = r"(?<=\[)\w+(?=\])";
1681                    let search_config = SearchConfig {
1682                        search_text: re_str,
1683                        match_whole_word: false,
1684                        fixed_strings: false,
1685                        advanced_regex: true,
1686                        match_case: false,
1687                        replacement_text: "ERROR",
1688                    };
1689                    let parsed = test_helpers::must_parse_search_config(search_config);
1690
1691                    assert_eq!(
1692                        replacement_if_match(
1693                            "Tag: [WARNING] message",
1694                            &parsed.search,
1695                            &parsed.replace
1696                        ),
1697                        Some("Tag: [ERROR] message".to_string())
1698                    );
1699                }
1700
1701                #[test]
1702                fn test_unicode_support() {
1703                    let re_str = r"\p{Greek}+";
1704                    let search_config = SearchConfig {
1705                        search_text: re_str,
1706                        match_whole_word: false,
1707                        fixed_strings: false,
1708                        advanced_regex: true,
1709                        match_case: false,
1710                        replacement_text: "GREEK",
1711                    };
1712                    let parsed = test_helpers::must_parse_search_config(search_config);
1713
1714                    assert_eq!(
1715                        replacement_if_match("Symbol: αβγδ", &parsed.search, &parsed.replace),
1716                        Some("Symbol: GREEK".to_string())
1717                    );
1718                }
1719            }
1720        }
1721
1722        #[test]
1723        fn test_multiple_replacements() {
1724            let search_config = SearchConfig {
1725                search_text: "world",
1726                fixed_strings: true,
1727                match_whole_word: true,
1728                match_case: false,
1729                replacement_text: "earth",
1730                advanced_regex: false,
1731            };
1732            let parsed = test_helpers::must_parse_search_config(search_config);
1733            assert_eq!(
1734                replacement_if_match("world hello world", &parsed.search, &parsed.replace),
1735                Some("earth hello earth".to_string())
1736            );
1737        }
1738
1739        #[test]
1740        fn test_no_match() {
1741            let search_config = SearchConfig {
1742                search_text: "world",
1743                fixed_strings: true,
1744                match_whole_word: true,
1745                match_case: false,
1746                replacement_text: "earth",
1747                advanced_regex: false,
1748            };
1749            let parsed = test_helpers::must_parse_search_config(search_config);
1750            assert_eq!(
1751                replacement_if_match("worldwide", &parsed.search, &parsed.replace),
1752                None
1753            );
1754            let search_config = SearchConfig {
1755                search_text: "world",
1756                fixed_strings: true,
1757                match_whole_word: true,
1758                match_case: false,
1759                replacement_text: "earth",
1760                advanced_regex: false,
1761            };
1762            let parsed = test_helpers::must_parse_search_config(search_config);
1763            assert_eq!(
1764                replacement_if_match("_world_", &parsed.search, &parsed.replace),
1765                None
1766            );
1767        }
1768
1769        #[test]
1770        fn test_word_boundaries() {
1771            let search_config = SearchConfig {
1772                search_text: "world",
1773                fixed_strings: true,
1774                match_whole_word: true,
1775                match_case: false,
1776                replacement_text: "earth",
1777                advanced_regex: false,
1778            };
1779            let parsed = test_helpers::must_parse_search_config(search_config);
1780            assert_eq!(
1781                replacement_if_match(",world-", &parsed.search, &parsed.replace),
1782                Some(",earth-".to_string())
1783            );
1784            let search_config = SearchConfig {
1785                search_text: "world",
1786                fixed_strings: true,
1787                match_whole_word: true,
1788                match_case: false,
1789                replacement_text: "earth",
1790                advanced_regex: false,
1791            };
1792            let parsed = test_helpers::must_parse_search_config(search_config);
1793            assert_eq!(
1794                replacement_if_match("world-word", &parsed.search, &parsed.replace),
1795                Some("earth-word".to_string())
1796            );
1797            let search_config = SearchConfig {
1798                search_text: "world",
1799                fixed_strings: true,
1800                match_whole_word: true,
1801                match_case: false,
1802                replacement_text: "earth",
1803                advanced_regex: false,
1804            };
1805            let parsed = test_helpers::must_parse_search_config(search_config);
1806            assert_eq!(
1807                replacement_if_match("Hello-world!", &parsed.search, &parsed.replace),
1808                Some("Hello-earth!".to_string())
1809            );
1810        }
1811
1812        #[test]
1813        fn test_case_sensitive() {
1814            let search_config = SearchConfig {
1815                search_text: "world",
1816                fixed_strings: true,
1817                match_whole_word: true,
1818                match_case: true,
1819                replacement_text: "earth",
1820                advanced_regex: false,
1821            };
1822            let parsed = test_helpers::must_parse_search_config(search_config);
1823            assert_eq!(
1824                replacement_if_match("Hello WORLD", &parsed.search, &parsed.replace),
1825                None
1826            );
1827            let search_config = SearchConfig {
1828                search_text: "wOrld",
1829                fixed_strings: true,
1830                match_whole_word: true,
1831                match_case: true,
1832                replacement_text: "earth",
1833                advanced_regex: false,
1834            };
1835            let parsed = test_helpers::must_parse_search_config(search_config);
1836            assert_eq!(
1837                replacement_if_match("Hello world", &parsed.search, &parsed.replace),
1838                None
1839            );
1840        }
1841
1842        #[test]
1843        fn test_empty_strings() {
1844            let search_config = SearchConfig {
1845                search_text: "world",
1846                fixed_strings: true,
1847                match_whole_word: true,
1848                match_case: false,
1849                replacement_text: "earth",
1850                advanced_regex: false,
1851            };
1852            let parsed = test_helpers::must_parse_search_config(search_config);
1853            assert_eq!(
1854                replacement_if_match("", &parsed.search, &parsed.replace),
1855                None
1856            );
1857            let search_config = SearchConfig {
1858                search_text: "",
1859                fixed_strings: true,
1860                match_whole_word: true,
1861                match_case: false,
1862                replacement_text: "earth",
1863                advanced_regex: false,
1864            };
1865            let parsed = test_helpers::must_parse_search_config(search_config);
1866            assert_eq!(
1867                replacement_if_match("hello world", &parsed.search, &parsed.replace),
1868                None
1869            );
1870        }
1871
1872        #[test]
1873        fn test_substring_no_match() {
1874            let search_config = SearchConfig {
1875                search_text: "world",
1876                fixed_strings: true,
1877                match_whole_word: true,
1878                match_case: false,
1879                replacement_text: "earth",
1880                advanced_regex: false,
1881            };
1882            let parsed = test_helpers::must_parse_search_config(search_config);
1883            assert_eq!(
1884                replacement_if_match("worldwide web", &parsed.search, &parsed.replace),
1885                None
1886            );
1887            let search_config = SearchConfig {
1888                search_text: "world",
1889                fixed_strings: true,
1890                match_whole_word: true,
1891                match_case: false,
1892                replacement_text: "earth",
1893                advanced_regex: false,
1894            };
1895            let parsed = test_helpers::must_parse_search_config(search_config);
1896            assert_eq!(
1897                replacement_if_match("underworld", &parsed.search, &parsed.replace),
1898                None
1899            );
1900        }
1901
1902        #[test]
1903        fn test_special_regex_chars() {
1904            let search_config = SearchConfig {
1905                search_text: "(world)",
1906                fixed_strings: true,
1907                match_whole_word: true,
1908                match_case: false,
1909                replacement_text: "earth",
1910                advanced_regex: false,
1911            };
1912            let parsed = test_helpers::must_parse_search_config(search_config);
1913            assert_eq!(
1914                replacement_if_match("hello (world)", &parsed.search, &parsed.replace),
1915                Some("hello earth".to_string())
1916            );
1917            let search_config = SearchConfig {
1918                search_text: "world.*",
1919                fixed_strings: true,
1920                match_whole_word: true,
1921                match_case: false,
1922                replacement_text: "ea+rth",
1923                advanced_regex: false,
1924            };
1925            let parsed = test_helpers::must_parse_search_config(search_config);
1926            assert_eq!(
1927                replacement_if_match("hello world.*", &parsed.search, &parsed.replace),
1928                Some("hello ea+rth".to_string())
1929            );
1930        }
1931
1932        #[test]
1933        fn test_basic_regex_patterns() {
1934            let re_str = r"ax*b";
1935            let search_config = SearchConfig {
1936                search_text: re_str,
1937                fixed_strings: false,
1938                match_whole_word: true,
1939                match_case: false,
1940                replacement_text: "NEW",
1941                advanced_regex: false,
1942            };
1943            let parsed = test_helpers::must_parse_search_config(search_config);
1944            assert_eq!(
1945                replacement_if_match("foo axxxxb bar", &parsed.search, &parsed.replace),
1946                Some("foo NEW bar".to_string())
1947            );
1948            let search_config = SearchConfig {
1949                search_text: re_str,
1950                fixed_strings: false,
1951                match_whole_word: true,
1952                match_case: false,
1953                replacement_text: "NEW",
1954                advanced_regex: false,
1955            };
1956            let parsed = test_helpers::must_parse_search_config(search_config);
1957            assert_eq!(
1958                replacement_if_match("fooaxxxxb bar", &parsed.search, &parsed.replace),
1959                None
1960            );
1961        }
1962
1963        #[test]
1964        fn test_patterns_with_spaces() {
1965            let re_str = r"hel+o world";
1966            let search_config = SearchConfig {
1967                search_text: re_str,
1968                fixed_strings: false,
1969                match_whole_word: true,
1970                match_case: false,
1971                replacement_text: "hi earth",
1972                advanced_regex: false,
1973            };
1974            let parsed = test_helpers::must_parse_search_config(search_config);
1975            assert_eq!(
1976                replacement_if_match("say hello world!", &parsed.search, &parsed.replace),
1977                Some("say hi earth!".to_string())
1978            );
1979            let search_config = SearchConfig {
1980                search_text: re_str,
1981                fixed_strings: false,
1982                match_whole_word: true,
1983                match_case: false,
1984                replacement_text: "hi earth",
1985                advanced_regex: false,
1986            };
1987            let parsed = test_helpers::must_parse_search_config(search_config);
1988            assert_eq!(
1989                replacement_if_match("helloworld", &parsed.search, &parsed.replace),
1990                None
1991            );
1992        }
1993
1994        #[test]
1995        fn test_multiple_matches() {
1996            let re_str = r"a+b+";
1997            let search_config = SearchConfig {
1998                search_text: re_str,
1999                fixed_strings: false,
2000                match_whole_word: true,
2001                match_case: false,
2002                replacement_text: "X",
2003                advanced_regex: false,
2004            };
2005            let parsed = test_helpers::must_parse_search_config(search_config);
2006            assert_eq!(
2007                replacement_if_match("foo aab abb", &parsed.search, &parsed.replace),
2008                Some("foo X X".to_string())
2009            );
2010            let search_config = SearchConfig {
2011                search_text: re_str,
2012                fixed_strings: false,
2013                match_whole_word: true,
2014                match_case: false,
2015                replacement_text: "X",
2016                advanced_regex: false,
2017            };
2018            let parsed = test_helpers::must_parse_search_config(search_config);
2019            assert_eq!(
2020                replacement_if_match("ab abaab abb", &parsed.search, &parsed.replace),
2021                Some("X abaab X".to_string())
2022            );
2023            let search_config = SearchConfig {
2024                search_text: re_str,
2025                fixed_strings: false,
2026                match_whole_word: true,
2027                match_case: false,
2028                replacement_text: "X",
2029                advanced_regex: false,
2030            };
2031            let parsed = test_helpers::must_parse_search_config(search_config);
2032            assert_eq!(
2033                replacement_if_match("ababaababb", &parsed.search, &parsed.replace),
2034                None
2035            );
2036            let search_config = SearchConfig {
2037                search_text: re_str,
2038                fixed_strings: false,
2039                match_whole_word: true,
2040                match_case: false,
2041                replacement_text: "X",
2042                advanced_regex: false,
2043            };
2044            let parsed = test_helpers::must_parse_search_config(search_config);
2045            assert_eq!(
2046                replacement_if_match("ab ab aab abb", &parsed.search, &parsed.replace),
2047                Some("X X X X".to_string())
2048            );
2049        }
2050
2051        #[test]
2052        fn test_boundary_cases() {
2053            let re_str = r"foo\s*bar";
2054            // At start of string
2055            let search_config = SearchConfig {
2056                search_text: re_str,
2057                fixed_strings: false,
2058                match_whole_word: true,
2059                match_case: false,
2060                replacement_text: "TEST",
2061                advanced_regex: false,
2062            };
2063            let parsed = test_helpers::must_parse_search_config(search_config);
2064            assert_eq!(
2065                replacement_if_match("foo bar baz", &parsed.search, &parsed.replace),
2066                Some("TEST baz".to_string())
2067            );
2068            // At end of string
2069            let search_config = SearchConfig {
2070                search_text: re_str,
2071                fixed_strings: false,
2072                match_whole_word: true,
2073                match_case: false,
2074                replacement_text: "TEST",
2075                advanced_regex: false,
2076            };
2077            let parsed = test_helpers::must_parse_search_config(search_config);
2078            assert_eq!(
2079                replacement_if_match("baz foo bar", &parsed.search, &parsed.replace),
2080                Some("baz TEST".to_string())
2081            );
2082            // With punctuation
2083            let search_config = SearchConfig {
2084                search_text: re_str,
2085                fixed_strings: false,
2086                match_whole_word: true,
2087                match_case: false,
2088                replacement_text: "TEST",
2089                advanced_regex: false,
2090            };
2091            let parsed = test_helpers::must_parse_search_config(search_config);
2092            assert_eq!(
2093                replacement_if_match("a (?( foo  bar)", &parsed.search, &parsed.replace),
2094                Some("a (?( TEST)".to_string())
2095            );
2096        }
2097
2098        #[test]
2099        fn test_with_punctuation() {
2100            let re_str = r"a\d+b";
2101            let search_config = SearchConfig {
2102                search_text: re_str,
2103                fixed_strings: false,
2104                match_whole_word: true,
2105                match_case: false,
2106                replacement_text: "X",
2107                advanced_regex: false,
2108            };
2109            let parsed = test_helpers::must_parse_search_config(search_config);
2110            assert_eq!(
2111                replacement_if_match("(a42b)", &parsed.search, &parsed.replace),
2112                Some("(X)".to_string())
2113            );
2114            let search_config = SearchConfig {
2115                search_text: re_str,
2116                fixed_strings: false,
2117                match_whole_word: true,
2118                match_case: false,
2119                replacement_text: "X",
2120                advanced_regex: false,
2121            };
2122            let parsed = test_helpers::must_parse_search_config(search_config);
2123            assert_eq!(
2124                replacement_if_match("foo.a123b!bar", &parsed.search, &parsed.replace),
2125                Some("foo.X!bar".to_string())
2126            );
2127        }
2128
2129        #[test]
2130        fn test_complex_patterns() {
2131            let re_str = r"[a-z]+\d+[a-z]+";
2132            let search_config = SearchConfig {
2133                search_text: re_str,
2134                fixed_strings: false,
2135                match_whole_word: true,
2136                match_case: false,
2137                replacement_text: "NEW",
2138                advanced_regex: false,
2139            };
2140            let parsed = test_helpers::must_parse_search_config(search_config);
2141            assert_eq!(
2142                replacement_if_match("test9 abc123def 8xyz", &parsed.search, &parsed.replace),
2143                Some("test9 NEW 8xyz".to_string())
2144            );
2145            let search_config = SearchConfig {
2146                search_text: re_str,
2147                fixed_strings: false,
2148                match_whole_word: true,
2149                match_case: false,
2150                replacement_text: "NEW",
2151                advanced_regex: false,
2152            };
2153            let parsed = test_helpers::must_parse_search_config(search_config);
2154            assert_eq!(
2155                replacement_if_match("test9abc123def8xyz", &parsed.search, &parsed.replace),
2156                None
2157            );
2158        }
2159
2160        #[test]
2161        fn test_optional_patterns() {
2162            let re_str = r"colou?r";
2163            let search_config = SearchConfig {
2164                search_text: re_str,
2165                fixed_strings: false,
2166                match_whole_word: true,
2167                match_case: false,
2168                replacement_text: "X",
2169                advanced_regex: false,
2170            };
2171            let parsed = test_helpers::must_parse_search_config(search_config);
2172            assert_eq!(
2173                replacement_if_match("my color and colour", &parsed.search, &parsed.replace),
2174                Some("my X and X".to_string())
2175            );
2176        }
2177
2178        #[test]
2179        fn test_empty_haystack() {
2180            let re_str = r"test";
2181            let search_config = SearchConfig {
2182                search_text: re_str,
2183                fixed_strings: false,
2184                match_whole_word: true,
2185                match_case: false,
2186                replacement_text: "NEW",
2187                advanced_regex: false,
2188            };
2189            let parsed = test_helpers::must_parse_search_config(search_config);
2190            assert_eq!(
2191                replacement_if_match("", &parsed.search, &parsed.replace),
2192                None
2193            );
2194        }
2195
2196        #[test]
2197        fn test_empty_search_regex() {
2198            let re_str = r"";
2199            let search_config = SearchConfig {
2200                search_text: re_str,
2201                fixed_strings: false,
2202                match_whole_word: true,
2203                match_case: false,
2204                replacement_text: "NEW",
2205                advanced_regex: false,
2206            };
2207            let parsed = test_helpers::must_parse_search_config(search_config);
2208            assert_eq!(
2209                replacement_if_match("search", &parsed.search, &parsed.replace),
2210                None
2211            );
2212        }
2213
2214        #[test]
2215        fn test_single_char() {
2216            let re_str = r"a";
2217            let search_config = SearchConfig {
2218                search_text: re_str,
2219                fixed_strings: false,
2220                match_whole_word: true,
2221                match_case: false,
2222                replacement_text: "X",
2223                advanced_regex: false,
2224            };
2225            let parsed = test_helpers::must_parse_search_config(search_config);
2226            assert_eq!(
2227                replacement_if_match("b a c", &parsed.search, &parsed.replace),
2228                Some("b X c".to_string())
2229            );
2230            let search_config = SearchConfig {
2231                search_text: re_str,
2232                fixed_strings: false,
2233                match_whole_word: true,
2234                match_case: false,
2235                replacement_text: "X",
2236                advanced_regex: false,
2237            };
2238            let parsed = test_helpers::must_parse_search_config(search_config);
2239            assert_eq!(
2240                replacement_if_match("bac", &parsed.search, &parsed.replace),
2241                None
2242            );
2243        }
2244
2245        #[test]
2246        fn test_escaped_chars() {
2247            let re_str = r"\(\d+\)";
2248            let search_config = SearchConfig {
2249                search_text: re_str,
2250                fixed_strings: false,
2251                match_whole_word: true,
2252                match_case: false,
2253                replacement_text: "X",
2254                advanced_regex: false,
2255            };
2256            let parsed = test_helpers::must_parse_search_config(search_config);
2257            assert_eq!(
2258                replacement_if_match("test (123) foo", &parsed.search, &parsed.replace),
2259                Some("test X foo".to_string())
2260            );
2261        }
2262
2263        #[test]
2264        fn test_with_unicode() {
2265            let re_str = r"λ\d+";
2266            let search_config = SearchConfig {
2267                search_text: re_str,
2268                fixed_strings: false,
2269                match_whole_word: true,
2270                match_case: false,
2271                replacement_text: "X",
2272                advanced_regex: false,
2273            };
2274            let parsed = test_helpers::must_parse_search_config(search_config);
2275            assert_eq!(
2276                replacement_if_match("calc λ123 β", &parsed.search, &parsed.replace),
2277                Some("calc X β".to_string())
2278            );
2279            let search_config = SearchConfig {
2280                search_text: re_str,
2281                fixed_strings: false,
2282                match_whole_word: true,
2283                match_case: false,
2284                replacement_text: "X",
2285                advanced_regex: false,
2286            };
2287            let parsed = test_helpers::must_parse_search_config(search_config);
2288            assert_eq!(
2289                replacement_if_match("calcλ123", &parsed.search, &parsed.replace),
2290                None
2291            );
2292        }
2293    }
2294}