frep_core/
replace.rs

1use crossterm::style::Stylize as _;
2use std::{
3    collections::HashMap,
4    fs::File,
5    io::{BufReader, BufWriter, Write},
6};
7use tempfile::NamedTempFile;
8
9use crate::line_reader::BufReadExt;
10use crate::search::SearchResult;
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
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ReplaceStats {
74    pub num_successes: usize,
75    pub errors: Vec<SearchResult>,
76}
77
78pub fn calculate_statistics<I>(results: I) -> ReplaceStats
79where
80    I: IntoIterator<Item = SearchResult>,
81{
82    let mut num_successes = 0;
83    let mut errors = vec![];
84
85    results.into_iter().for_each(|res| {
86        assert!(
87            res.included,
88            "Expected only included results, found {res:?}"
89        );
90        match &res.replace_result {
91            Some(ReplaceResult::Success) => {
92                num_successes += 1;
93            }
94            None => {
95                let mut res = res.clone();
96                res.replace_result = Some(ReplaceResult::Error(
97                    "Failed to find search result in file".to_owned(),
98                ));
99                errors.push(res);
100            }
101            Some(ReplaceResult::Error(_)) => {
102                errors.push(res.clone());
103            }
104        }
105    });
106
107    ReplaceStats {
108        num_successes,
109        errors,
110    }
111}
112
113pub fn format_replacement_results(
114    num_successes: usize,
115    num_ignored: Option<usize>,
116    errors: Option<&[SearchResult]>,
117) -> String {
118    let errors_display = if let Some(errors) = errors {
119        #[allow(clippy::format_collect)]
120        errors
121            .iter()
122            .map(|error| {
123                let (path, error) = error.display_error();
124                format!("\n{path}:\n  {}", error.red())
125            })
126            .collect::<String>()
127    } else {
128        String::new()
129    };
130
131    let maybe_ignored_str = match num_ignored {
132        Some(n) => format!("\nIgnored (lines): {n}"),
133        None => "".into(),
134    };
135    let maybe_errors_str = match errors {
136        Some(errors) => format!(
137            "\nErrors: {num_errors}{errors_display}",
138            num_errors = errors.len()
139        ),
140        None => "".into(),
141    };
142
143    format!("Successful replacements (lines): {num_successes}{maybe_ignored_str}{maybe_errors_str}")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::line_reader::LineEnding;
150    use crate::search::SearchResult;
151    use std::path::PathBuf;
152    use tempfile::TempDir;
153
154    fn create_search_result(
155        path: &str,
156        line_number: usize,
157        line: &str,
158        replacement: &str,
159        included: bool,
160        replace_result: Option<ReplaceResult>,
161    ) -> SearchResult {
162        SearchResult {
163            path: PathBuf::from(path),
164            line_number,
165            line: line.to_string(),
166            line_ending: LineEnding::Lf,
167            replacement: replacement.to_string(),
168            included,
169            replace_result,
170        }
171    }
172
173    #[test]
174    fn test_replace_in_file_success() {
175        let temp_dir = TempDir::new().unwrap();
176        let file_path = temp_dir.path().join("test.txt");
177
178        // Create test file
179        let content = "line 1\nold text\nline 3\nold text\nline 5\n";
180        std::fs::write(&file_path, content).unwrap();
181
182        // Create search results
183        let mut results = vec![
184            create_search_result(
185                file_path.to_str().unwrap(),
186                2,
187                "old text",
188                "new text",
189                true,
190                None,
191            ),
192            create_search_result(
193                file_path.to_str().unwrap(),
194                4,
195                "old text",
196                "new text",
197                true,
198                None,
199            ),
200        ];
201
202        // Perform replacement
203        let result = replace_in_file(&mut results);
204        assert!(result.is_ok());
205
206        // Verify replacements were marked as successful
207        assert_eq!(results.len(), 2);
208        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
209        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
210
211        // Verify file content
212        let new_content = std::fs::read_to_string(&file_path).unwrap();
213        assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5\n");
214    }
215
216    #[test]
217    fn test_replace_in_file_success_no_final_newline() {
218        let temp_dir = TempDir::new().unwrap();
219        let file_path = temp_dir.path().join("test.txt");
220
221        // Create test file
222        let content = "line 1\nold text\nline 3\nold text\nline 5";
223        std::fs::write(&file_path, content).unwrap();
224
225        // Create search results
226        let mut results = vec![
227            create_search_result(
228                file_path.to_str().unwrap(),
229                2,
230                "old text",
231                "new text",
232                true,
233                None,
234            ),
235            create_search_result(
236                file_path.to_str().unwrap(),
237                4,
238                "old text",
239                "new text",
240                true,
241                None,
242            ),
243        ];
244
245        // Perform replacement
246        let result = replace_in_file(&mut results);
247        assert!(result.is_ok());
248
249        // Verify replacements were marked as successful
250        assert_eq!(results.len(), 2);
251        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
252        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
253
254        // Verify file content
255        let new_content = std::fs::read_to_string(&file_path).unwrap();
256        assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5");
257    }
258
259    #[test]
260    fn test_replace_in_file_success_windows_newlines() {
261        let temp_dir = TempDir::new().unwrap();
262        let file_path = temp_dir.path().join("test.txt");
263
264        // Create test file
265        let content = "line 1\r\nold text\r\nline 3\r\nold text\r\nline 5\r\n";
266        std::fs::write(&file_path, content).unwrap();
267
268        // Create search results
269        let mut results = vec![
270            create_search_result(
271                file_path.to_str().unwrap(),
272                2,
273                "old text",
274                "new text",
275                true,
276                None,
277            ),
278            create_search_result(
279                file_path.to_str().unwrap(),
280                4,
281                "old text",
282                "new text",
283                true,
284                None,
285            ),
286        ];
287
288        // Perform replacement
289        let result = replace_in_file(&mut results);
290        assert!(result.is_ok());
291
292        // Verify replacements were marked as successful
293        assert_eq!(results.len(), 2);
294        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
295        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
296
297        // Verify file content
298        let new_content = std::fs::read_to_string(&file_path).unwrap();
299        assert_eq!(
300            new_content,
301            "line 1\r\nnew text\r\nline 3\r\nnew text\r\nline 5\r\n"
302        );
303    }
304
305    #[test]
306    fn test_replace_in_file_success_mixed_newlines() {
307        let temp_dir = TempDir::new().unwrap();
308        let file_path = temp_dir.path().join("test.txt");
309
310        // Create test file
311        let content = "\n\r\nline 1\nold text\r\nline 3\nline 4\r\nline 5\r\n\n\n";
312        std::fs::write(&file_path, content).unwrap();
313
314        // Create search results
315        let mut results = vec![
316            create_search_result(
317                file_path.to_str().unwrap(),
318                4,
319                "old text",
320                "new text",
321                true,
322                None,
323            ),
324            create_search_result(
325                file_path.to_str().unwrap(),
326                7,
327                "line 5",
328                "updated line 5",
329                true,
330                None,
331            ),
332        ];
333
334        // Perform replacement
335        let result = replace_in_file(&mut results);
336        assert!(result.is_ok());
337
338        // Verify replacements were marked as successful
339        assert_eq!(results.len(), 2);
340        assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
341        assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
342
343        // Verify file content
344        let new_content = std::fs::read_to_string(&file_path).unwrap();
345        assert_eq!(
346            new_content,
347            "\n\r\nline 1\nnew text\r\nline 3\nline 4\r\nupdated line 5\r\n\n\n"
348        );
349    }
350
351    #[test]
352    fn test_replace_in_file_line_mismatch() {
353        let temp_dir = TempDir::new().unwrap();
354        let file_path = temp_dir.path().join("test.txt");
355
356        // Create test file
357        let content = "line 1\nactual text\nline 3\n";
358        std::fs::write(&file_path, content).unwrap();
359
360        // Create search result with mismatching line
361        let mut results = vec![create_search_result(
362            file_path.to_str().unwrap(),
363            2,
364            "expected text",
365            "new text",
366            true,
367            None,
368        )];
369
370        // Perform replacement
371        let result = replace_in_file(&mut results);
372        assert!(result.is_ok());
373
374        // Verify replacement was marked as error
375        assert_eq!(
376            results[0].replace_result,
377            Some(ReplaceResult::Error(
378                "File changed since last search".to_owned()
379            ))
380        );
381
382        // Verify file content is unchanged (except for newlines)
383        let new_content = std::fs::read_to_string(&file_path).unwrap();
384        assert_eq!(new_content, "line 1\nactual text\nline 3\n");
385    }
386
387    #[test]
388    fn test_replace_in_file_nonexistent_file() {
389        let mut results = vec![create_search_result(
390            "/nonexistent/path/file.txt",
391            1,
392            "old",
393            "new",
394            true,
395            None,
396        )];
397
398        let result = replace_in_file(&mut results);
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_replace_in_file_no_parent_directory() {
404        let mut results = vec![SearchResult {
405            path: PathBuf::from("/"),
406            line_number: 0,
407            line: "foo".into(),
408            line_ending: LineEnding::Lf,
409            replacement: "bar".into(),
410            included: true,
411            replace_result: None,
412        }];
413
414        let result = replace_in_file(&mut results);
415        assert!(result.is_err());
416        if let Err(e) = result {
417            assert!(e.to_string().contains("no parent directory"));
418        }
419    }
420
421    #[test]
422    fn test_format_replacement_results_no_errors() {
423        let result = format_replacement_results(5, Some(2), Some(&[]));
424        assert_eq!(
425            result,
426            "Successful replacements (lines): 5\nIgnored (lines): 2\nErrors: 0"
427        );
428    }
429
430    #[test]
431    fn test_format_replacement_results_with_errors() {
432        let error_result = create_search_result(
433            "file.txt",
434            10,
435            "line",
436            "replacement",
437            true,
438            Some(ReplaceResult::Error("Test error".to_string())),
439        );
440
441        let result = format_replacement_results(3, Some(1), Some(&[error_result]));
442        assert!(result.contains("Successful replacements (lines): 3"));
443        assert!(result.contains("Ignored (lines): 1"));
444        assert!(result.contains("Errors: 1"));
445        assert!(result.contains("file.txt:10"));
446        assert!(result.contains("Test error"));
447    }
448
449    #[test]
450    fn test_format_replacement_results_no_ignored_count() {
451        let result = format_replacement_results(7, None, Some(&[]));
452        assert_eq!(result, "Successful replacements (lines): 7\nErrors: 0");
453        assert!(!result.contains("Ignored (lines):"));
454    }
455}