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
18pub 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 {
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; 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 let content = "line 1\nold text\nline 3\nold text\nline 5\n";
180 std::fs::write(&file_path, content).unwrap();
181
182 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 let result = replace_in_file(&mut results);
204 assert!(result.is_ok());
205
206 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 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 let content = "line 1\nold text\nline 3\nold text\nline 5";
223 std::fs::write(&file_path, content).unwrap();
224
225 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 let result = replace_in_file(&mut results);
247 assert!(result.is_ok());
248
249 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 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 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 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 let result = replace_in_file(&mut results);
290 assert!(result.is_ok());
291
292 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 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 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 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 let result = replace_in_file(&mut results);
336 assert!(result.is_ok());
337
338 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 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 let content = "line 1\nactual text\nline 3\n";
358 std::fs::write(&file_path, content).unwrap();
359
360 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 let result = replace_in_file(&mut results);
372 assert!(result.is_ok());
373
374 assert_eq!(
376 results[0].replace_result,
377 Some(ReplaceResult::Error(
378 "File changed since last search".to_owned()
379 ))
380 );
381
382 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}