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, 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 [SearchResult]) -> anyhow::Result<()> {
21    let file_path = match results {
22        [r, ..] => r.path.clone(),
23        [] => return Ok(()),
24    };
25    debug_assert!(results.iter().all(|r| r.path == file_path));
26
27    let mut line_map: HashMap<_, _> = results
28        .iter_mut()
29        .map(|res| (res.line_number, res))
30        .collect();
31
32    let parent_dir = file_path.parent().ok_or_else(|| {
33        anyhow::anyhow!(
34            "Cannot create temp file: target path '{}' has no parent directory",
35            file_path.display()
36        )
37    })?;
38    let temp_output_file = NamedTempFile::new_in(parent_dir)?;
39
40    // Scope the file operations so they're closed before rename
41    {
42        let input = File::open(file_path.clone())?;
43        let reader = BufReader::new(input);
44
45        let output = File::create(temp_output_file.path())?;
46        let mut writer = BufWriter::new(output);
47
48        for (mut line_number, line_result) in reader.lines_with_endings().enumerate() {
49            line_number += 1; // Ensure line-number is 1-indexed
50            let (mut line, line_ending) = line_result?;
51            if let Some(res) = line_map.get_mut(&line_number) {
52                if line == res.line.as_bytes() {
53                    line = res.replacement.as_bytes().to_vec();
54                    res.replace_result = Some(ReplaceResult::Success);
55                } else {
56                    res.replace_result = Some(ReplaceResult::Error(
57                        "File changed since last search".to_owned(),
58                    ));
59                }
60            }
61            line.extend(line_ending.as_bytes());
62            writer.write_all(&line)?;
63        }
64
65        writer.flush()?;
66    }
67
68    temp_output_file.persist(file_path)?;
69    Ok(())
70}
71
72const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
73
74fn should_replace_in_memory(path: &Path) -> Result<bool, std::io::Error> {
75    let file_size = fs::metadata(path)?.len();
76    Ok(file_size <= MAX_FILE_SIZE)
77}
78
79/// Performs search and replace operations in a file
80///
81/// This function implements a hybrid approach to file replacements:
82/// 1. For files under the `MAX_FILE_SIZE` threshold, it attempts an in-memory replacement
83/// 2. If the file is large or in-memory replacement fails, it falls back to line-by-line chunked replacement
84///
85/// This approach optimizes for performance while maintaining reasonable memory usage limits.
86///
87/// # Arguments
88///
89/// * `file_path` - Path to the file to process
90/// * `search` - The search pattern (fixed string, regex, or advanced regex)
91/// * `replace` - The replacement string
92///
93/// # Returns
94///
95/// * `Ok(true)` if replacements were made in the file
96/// * `Ok(false)` if no replacements were made (no matches found)
97/// * `Err` if any errors occurred during the operation
98pub fn replace_all_in_file(
99    file_path: &Path,
100    search: &SearchType,
101    replace: &str,
102) -> anyhow::Result<bool> {
103    // Try to read into memory if not too large - if this fails, or if too large, fall back to line-by-line replacement
104    if matches!(should_replace_in_memory(file_path), Ok(true)) {
105        match replace_in_memory(file_path, search, replace) {
106            Ok(replaced) => return Ok(replaced),
107            Err(e) => {
108                log::error!(
109                    "Found error when attempting to replace in memory for file {path_display}: {e}",
110                    path_display = file_path.display(),
111                );
112            }
113        }
114    }
115
116    replace_chunked(file_path, search, replace)
117}
118
119fn replace_chunked(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
120    let mut results = search::search_file(file_path, search, replace)?;
121    if !results.is_empty() {
122        replace_in_file(&mut results)?;
123        return Ok(true);
124    }
125
126    Ok(false)
127}
128
129fn replace_in_memory(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
130    let content = fs::read_to_string(file_path)?;
131    if let Some(new_content) = replacement_if_match(&content, search, replace) {
132        let parent_dir = file_path.parent().unwrap_or(Path::new("."));
133        let mut temp_file = NamedTempFile::new_in(parent_dir)?;
134        temp_file.write_all(new_content.as_bytes())?;
135        temp_file.persist(file_path)?;
136        Ok(true)
137    } else {
138        Ok(false)
139    }
140}
141
142/// Performs a search and replace operation on a string if the pattern matches
143///
144/// # Arguments
145///
146/// * `line` - The string to search within
147/// * `search` - The search pattern (fixed string, regex, or advanced regex)
148/// * `replace` - The replacement string
149///
150/// # Returns
151///
152/// * `Some(String)` containing the string with replacements if matches were found
153/// * `None` if no matches were found
154pub fn replacement_if_match(line: &str, search: &SearchType, replace: &str) -> Option<String> {
155    if line.is_empty() || search.is_empty() {
156        return None;
157    }
158
159    match search {
160        SearchType::Fixed(fixed_str) => {
161            if line.contains(fixed_str) {
162                Some(line.replace(fixed_str, replace))
163            } else {
164                None
165            }
166        }
167        SearchType::Pattern(pattern) => {
168            if pattern.is_match(line) {
169                Some(pattern.replace_all(line, replace).to_string())
170            } else {
171                None
172            }
173        }
174        SearchType::PatternAdvanced(pattern) => match pattern.is_match(line) {
175            Ok(true) => Some(pattern.replace_all(line, replace).to_string()),
176            _ => None,
177        },
178    }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct ReplaceStats {
183    pub num_successes: usize,
184    pub errors: Vec<SearchResult>,
185}
186
187pub fn calculate_statistics<I>(results: I) -> ReplaceStats
188where
189    I: IntoIterator<Item = SearchResult>,
190{
191    let mut num_successes = 0;
192    let mut errors = vec![];
193
194    results.into_iter().for_each(|res| {
195        assert!(
196            res.included,
197            "Expected only included results, found {res:?}"
198        );
199        match &res.replace_result {
200            Some(ReplaceResult::Success) => {
201                num_successes += 1;
202            }
203            None => {
204                let mut res = res.clone();
205                res.replace_result = Some(ReplaceResult::Error(
206                    "Failed to find search result in file".to_owned(),
207                ));
208                errors.push(res);
209            }
210            Some(ReplaceResult::Error(_)) => {
211                errors.push(res.clone());
212            }
213        }
214    });
215
216    ReplaceStats {
217        num_successes,
218        errors,
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::line_reader::LineEnding;
226    use crate::search::{SearchResult, SearchType};
227    use regex::Regex;
228    use std::path::PathBuf;
229    use tempfile::TempDir;
230
231    // Helper functions
232    fn create_search_result(
233        path: &str,
234        line_number: usize,
235        line: &str,
236        replacement: &str,
237        included: bool,
238        replace_result: Option<ReplaceResult>,
239    ) -> SearchResult {
240        SearchResult {
241            path: PathBuf::from(path),
242            line_number,
243            line: line.to_string(),
244            line_ending: LineEnding::Lf,
245            replacement: replacement.to_string(),
246            included,
247            replace_result,
248        }
249    }
250
251    fn create_test_file(temp_dir: &TempDir, name: &str, content: &str) -> PathBuf {
252        let file_path = temp_dir.path().join(name);
253        std::fs::write(&file_path, content).unwrap();
254        file_path
255    }
256
257    fn assert_file_content(file_path: &Path, expected_content: &str) {
258        let content = std::fs::read_to_string(file_path).unwrap();
259        assert_eq!(content, expected_content);
260    }
261
262    fn fixed_search(pattern: &str) -> SearchType {
263        SearchType::Fixed(pattern.to_string())
264    }
265
266    fn regex_search(pattern: &str) -> SearchType {
267        SearchType::Pattern(Regex::new(pattern).unwrap())
268    }
269
270    // Tests for replace_in_file
271    #[test]
272    fn test_replace_in_file_success() {
273        let temp_dir = TempDir::new().unwrap();
274        let file_path = create_test_file(
275            &temp_dir,
276            "test.txt",
277            "line 1\nold text\nline 3\nold text\nline 5\n",
278        );
279
280        // Create search results
281        let mut results = vec![
282            create_search_result(
283                file_path.to_str().unwrap(),
284                2,
285                "old text",
286                "new text",
287                true,
288                None,
289            ),
290            create_search_result(
291                file_path.to_str().unwrap(),
292                4,
293                "old text",
294                "new text",
295                true,
296                None,
297            ),
298        ];
299
300        // Perform replacement
301        let result = replace_in_file(&mut results);
302        assert!(result.is_ok());
303
304        // Verify replacements were marked as successful
305        assert_eq!(results.len(), 2);
306        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
307        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
308
309        // Verify file content
310        assert_file_content(&file_path, "line 1\nnew text\nline 3\nnew text\nline 5\n");
311    }
312
313    #[test]
314    fn test_replace_in_file_success_no_final_newline() {
315        let temp_dir = TempDir::new().unwrap();
316        let file_path = create_test_file(
317            &temp_dir,
318            "test.txt",
319            "line 1\nold text\nline 3\nold text\nline 5",
320        );
321
322        // Create search results
323        let mut results = vec![
324            create_search_result(
325                file_path.to_str().unwrap(),
326                2,
327                "old text",
328                "new text",
329                true,
330                None,
331            ),
332            create_search_result(
333                file_path.to_str().unwrap(),
334                4,
335                "old text",
336                "new text",
337                true,
338                None,
339            ),
340        ];
341
342        // Perform replacement
343        let result = replace_in_file(&mut results);
344        assert!(result.is_ok());
345
346        // Verify replacements were marked as successful
347        assert_eq!(results.len(), 2);
348        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
349        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
350
351        // Verify file content
352        let new_content = std::fs::read_to_string(&file_path).unwrap();
353        assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5");
354    }
355
356    #[test]
357    fn test_replace_in_file_success_windows_newlines() {
358        let temp_dir = TempDir::new().unwrap();
359        let file_path = create_test_file(
360            &temp_dir,
361            "test.txt",
362            "line 1\r\nold text\r\nline 3\r\nold text\r\nline 5\r\n",
363        );
364
365        // Create search results
366        let mut results = vec![
367            create_search_result(
368                file_path.to_str().unwrap(),
369                2,
370                "old text",
371                "new text",
372                true,
373                None,
374            ),
375            create_search_result(
376                file_path.to_str().unwrap(),
377                4,
378                "old text",
379                "new text",
380                true,
381                None,
382            ),
383        ];
384
385        // Perform replacement
386        let result = replace_in_file(&mut results);
387        assert!(result.is_ok());
388
389        // Verify replacements were marked as successful
390        assert_eq!(results.len(), 2);
391        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
392        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
393
394        // Verify file content
395        let new_content = std::fs::read_to_string(&file_path).unwrap();
396        assert_eq!(
397            new_content,
398            "line 1\r\nnew text\r\nline 3\r\nnew text\r\nline 5\r\n"
399        );
400    }
401
402    #[test]
403    fn test_replace_in_file_success_mixed_newlines() {
404        let temp_dir = TempDir::new().unwrap();
405        let file_path = create_test_file(
406            &temp_dir,
407            "test.txt",
408            "\n\r\nline 1\nold text\r\nline 3\nline 4\r\nline 5\r\n\n\n",
409        );
410
411        // Create search results
412        let mut results = vec![
413            create_search_result(
414                file_path.to_str().unwrap(),
415                4,
416                "old text",
417                "new text",
418                true,
419                None,
420            ),
421            create_search_result(
422                file_path.to_str().unwrap(),
423                7,
424                "line 5",
425                "updated line 5",
426                true,
427                None,
428            ),
429        ];
430
431        // Perform replacement
432        let result = replace_in_file(&mut results);
433        assert!(result.is_ok());
434
435        // Verify replacements were marked as successful
436        assert_eq!(results.len(), 2);
437        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
438        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
439
440        // Verify file content
441        let new_content = std::fs::read_to_string(&file_path).unwrap();
442        assert_eq!(
443            new_content,
444            "\n\r\nline 1\nnew text\r\nline 3\nline 4\r\nupdated line 5\r\n\n\n"
445        );
446    }
447
448    #[test]
449    fn test_replace_in_file_line_mismatch() {
450        let temp_dir = TempDir::new().unwrap();
451        let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nactual text\nline 3\n");
452
453        // Create search result with mismatching line
454        let mut results = vec![create_search_result(
455            file_path.to_str().unwrap(),
456            2,
457            "expected text",
458            "new text",
459            true,
460            None,
461        )];
462
463        // Perform replacement
464        let result = replace_in_file(&mut results);
465        assert!(result.is_ok());
466
467        // Verify replacement was marked as error
468        assert_eq!(
469            results[0].replace_result,
470            Some(ReplaceResult::Error(
471                "File changed since last search".to_owned()
472            ))
473        );
474
475        // Verify file content is unchanged
476        let new_content = std::fs::read_to_string(&file_path).unwrap();
477        assert_eq!(new_content, "line 1\nactual text\nline 3\n");
478    }
479
480    #[test]
481    fn test_replace_in_file_nonexistent_file() {
482        let mut results = vec![create_search_result(
483            "/nonexistent/path/file.txt",
484            1,
485            "old",
486            "new",
487            true,
488            None,
489        )];
490
491        let result = replace_in_file(&mut results);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn test_replace_in_file_no_parent_directory() {
497        let mut results = vec![SearchResult {
498            path: PathBuf::from("/"),
499            line_number: 0,
500            line: "foo".into(),
501            line_ending: LineEnding::Lf,
502            replacement: "bar".into(),
503            included: true,
504            replace_result: None,
505        }];
506
507        let result = replace_in_file(&mut results);
508        assert!(result.is_err());
509        if let Err(e) = result {
510            assert!(e.to_string().contains("no parent directory"));
511        }
512    }
513
514    // Tests for replace_in_memory
515    #[test]
516    fn test_replace_in_memory() {
517        let temp_dir = TempDir::new().unwrap();
518
519        // Test with fixed string
520        let file_path = create_test_file(
521            &temp_dir,
522            "test.txt",
523            "This is a test.\nIt contains search_term that should be replaced.\nMultiple lines with search_term here.",
524        );
525
526        let result = replace_in_memory(&file_path, &fixed_search("search_term"), "replacement");
527        assert!(result.is_ok());
528        assert!(result.unwrap()); // Should return true for modifications
529
530        assert_file_content(
531            &file_path,
532            "This is a test.\nIt contains replacement that should be replaced.\nMultiple lines with replacement here.",
533        );
534
535        // Test with regex pattern
536        let regex_path = create_test_file(
537            &temp_dir,
538            "regex_test.txt",
539            "Number: 123, Code: 456, ID: 789",
540        );
541
542        let result = replace_in_memory(&regex_path, &regex_search(r"\d{3}"), "XXX");
543        assert!(result.is_ok());
544        assert!(result.unwrap());
545
546        assert_file_content(&regex_path, "Number: XXX, Code: XXX, ID: XXX");
547    }
548
549    #[test]
550    fn test_replace_in_memory_no_match() {
551        let temp_dir = TempDir::new().unwrap();
552        let file_path = create_test_file(
553            &temp_dir,
554            "no_match.txt",
555            "This is a test file with no matches.",
556        );
557
558        let result = replace_in_memory(&file_path, &fixed_search("nonexistent"), "replacement");
559        assert!(result.is_ok());
560        assert!(!result.unwrap()); // Should return false for no modifications
561
562        // Verify file content unchanged
563        assert_file_content(&file_path, "This is a test file with no matches.");
564    }
565
566    #[test]
567    fn test_replace_in_memory_empty_file() {
568        let temp_dir = TempDir::new().unwrap();
569        let file_path = create_test_file(&temp_dir, "empty.txt", "");
570
571        let result = replace_in_memory(&file_path, &fixed_search("anything"), "replacement");
572        assert!(result.is_ok());
573        assert!(!result.unwrap());
574
575        // Verify file still empty
576        assert_file_content(&file_path, "");
577    }
578
579    #[test]
580    fn test_replace_in_memory_nonexistent_file() {
581        let result = replace_in_memory(
582            Path::new("/nonexistent/path/file.txt"),
583            &fixed_search("test"),
584            "replacement",
585        );
586        assert!(result.is_err());
587    }
588
589    // Tests for replace_chunked
590    #[test]
591    fn test_replace_chunked() {
592        let temp_dir = TempDir::new().unwrap();
593
594        // Test with fixed string
595        let file_path = create_test_file(
596            &temp_dir,
597            "test.txt",
598            "This is line one.\nThis contains search_pattern to replace.\nAnother line with search_pattern here.\nFinal line.",
599        );
600
601        let result = replace_chunked(&file_path, &fixed_search("search_pattern"), "replacement");
602        assert!(result.is_ok());
603        assert!(result.unwrap()); // Check that replacement happened
604
605        assert_file_content(
606            &file_path,
607            "This is line one.\nThis contains replacement to replace.\nAnother line with replacement here.\nFinal line.",
608        );
609
610        // Test with regex pattern
611        let regex_path = create_test_file(
612            &temp_dir,
613            "regex.txt",
614            "Line with numbers: 123 and 456.\nAnother line with 789.",
615        );
616
617        let result = replace_chunked(&regex_path, &regex_search(r"\d{3}"), "XXX");
618        assert!(result.is_ok());
619        assert!(result.unwrap());
620
621        assert_file_content(
622            &regex_path,
623            "Line with numbers: XXX and XXX.\nAnother line with XXX.",
624        );
625    }
626
627    #[test]
628    fn test_replace_chunked_no_match() {
629        let temp_dir = TempDir::new().unwrap();
630        let file_path = create_test_file(
631            &temp_dir,
632            "test.txt",
633            "This is a test file with no matching patterns.",
634        );
635
636        let result = replace_chunked(&file_path, &fixed_search("nonexistent"), "replacement");
637        assert!(result.is_ok());
638        assert!(!result.unwrap());
639
640        // Verify file content unchanged
641        assert_file_content(&file_path, "This is a test file with no matching patterns.");
642    }
643
644    #[test]
645    fn test_replace_chunked_empty_file() {
646        let temp_dir = TempDir::new().unwrap();
647        let file_path = create_test_file(&temp_dir, "empty.txt", "");
648
649        let result = replace_chunked(&file_path, &fixed_search("anything"), "replacement");
650        assert!(result.is_ok());
651        assert!(!result.unwrap());
652
653        // Verify file still empty
654        assert_file_content(&file_path, "");
655    }
656
657    #[test]
658    fn test_replace_chunked_nonexistent_file() {
659        let result = replace_chunked(
660            Path::new("/nonexistent/path/file.txt"),
661            &fixed_search("test"),
662            "replacement",
663        );
664        assert!(result.is_err());
665    }
666
667    // Tests for replace_all_in_file
668    #[test]
669    fn test_replace_all_in_file() {
670        let temp_dir = TempDir::new().unwrap();
671        let file_path = create_test_file(
672            &temp_dir,
673            "test.txt",
674            "This is a test file.\nIt has some content to replace.\nThe word replace should be replaced.",
675        );
676
677        let result = replace_all_in_file(&file_path, &fixed_search("replace"), "modify");
678        assert!(result.is_ok());
679        assert!(result.unwrap());
680
681        assert_file_content(
682            &file_path,
683            "This is a test file.\nIt has some content to modify.\nThe word modify should be modifyd.",
684        );
685    }
686}