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
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
72const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; fn 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
79pub fn replace_all_in_file(
99 file_path: &Path,
100 search: &SearchType,
101 replace: &str,
102) -> anyhow::Result<bool> {
103 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
142pub 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 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 #[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 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 let result = replace_in_file(&mut results);
302 assert!(result.is_ok());
303
304 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 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 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 let result = replace_in_file(&mut results);
344 assert!(result.is_ok());
345
346 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 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 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 let result = replace_in_file(&mut results);
387 assert!(result.is_ok());
388
389 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 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 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 let result = replace_in_file(&mut results);
433 assert!(result.is_ok());
434
435 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 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 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 let result = replace_in_file(&mut results);
465 assert!(result.is_ok());
466
467 assert_eq!(
469 results[0].replace_result,
470 Some(ReplaceResult::Error(
471 "File changed since last search".to_owned()
472 ))
473 );
474
475 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 #[test]
516 fn test_replace_in_memory() {
517 let temp_dir = TempDir::new().unwrap();
518
519 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()); 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 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(®ex_path, ®ex_search(r"\d{3}"), "XXX");
543 assert!(result.is_ok());
544 assert!(result.unwrap());
545
546 assert_file_content(®ex_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()); 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 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 #[test]
591 fn test_replace_chunked() {
592 let temp_dir = TempDir::new().unwrap();
593
594 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()); 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 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(®ex_path, ®ex_search(r"\d{3}"), "XXX");
618 assert!(result.is_ok());
619 assert!(result.unwrap());
620
621 assert_file_content(
622 ®ex_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 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 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 #[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}