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}