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