1use anyhow::Context;
2use rayon::prelude::{IntoParallelIterator, ParallelIterator};
3use std::{
4 collections::HashMap,
5 fs::{self, File},
6 io::{BufReader, BufWriter, Write},
7 num::NonZero,
8 path::{Path, PathBuf},
9 sync::{
10 Arc,
11 atomic::{AtomicBool, AtomicUsize, Ordering},
12 },
13 thread,
14 time::{Duration, Instant},
15};
16use tempfile::NamedTempFile;
17use tokio::{
18 sync::mpsc::{UnboundedReceiver, UnboundedSender},
19 task::JoinHandle,
20};
21
22use crate::{
23 app::{BackgroundProcessingEvent, Event, EventHandlingResult},
24 commands::CommandResults,
25 file_content::FileContentProvider,
26 line_reader::BufReadExt,
27 replace,
28 search::{
29 self, FileSearcher, MatchContent, MatchMode, SearchResult, SearchResultWithReplacement,
30 SearchType,
31 },
32};
33
34#[cfg(unix)]
35fn create_temp_file_in_with_permissions(
36 parent_dir: &Path,
37 original_file_path: &Path,
38) -> anyhow::Result<NamedTempFile> {
39 let original_permissions = fs::metadata(original_file_path)?.permissions();
40 let temp_file = NamedTempFile::new_in(parent_dir)?;
41 fs::set_permissions(temp_file.path(), original_permissions)?;
42 Ok(temp_file)
43}
44
45#[cfg(not(unix))]
46fn create_temp_file_in_with_permissions(
47 parent_dir: &Path,
48 _original_file_path: &Path,
49) -> anyhow::Result<NamedTempFile> {
50 Ok(NamedTempFile::new_in(parent_dir)?)
51}
52
53pub fn split_results(
54 results: Vec<SearchResultWithReplacement>,
55) -> (
56 Vec<SearchResultWithReplacement>,
57 Vec<SearchResultWithReplacement>,
58 usize,
59) {
60 let (included, excluded): (Vec<_>, Vec<_>) = results
61 .into_iter()
62 .partition(|res| res.search_result.included);
63 let num_ignored = excluded.len();
64 let (replaceable, preview_errored): (Vec<_>, Vec<_>) = included
65 .into_iter()
66 .partition(|res| res.preview_error.is_none());
67 (replaceable, preview_errored, num_ignored)
68}
69
70fn group_results(
71 included: Vec<SearchResultWithReplacement>,
72) -> HashMap<Option<PathBuf>, Vec<SearchResultWithReplacement>> {
73 let mut path_groups = HashMap::<Option<PathBuf>, Vec<SearchResultWithReplacement>>::new();
74 for res in included {
75 path_groups
76 .entry(res.search_result.path.clone())
77 .or_default()
78 .push(res);
79 }
80 path_groups
81}
82
83pub fn spawn_replace_included<T: Fn(SearchResultWithReplacement) + Send + Sync + 'static>(
84 search_results: Vec<SearchResultWithReplacement>,
85 cancelled: Arc<AtomicBool>,
86 replacements_completed: Arc<AtomicUsize>,
87 validation_search_config: Option<FileSearcher>,
88 file_content_provider: Arc<dyn FileContentProvider>,
89 on_completion: T,
90) -> usize {
91 let (included, preview_errored, num_ignored) = split_results(search_results);
92
93 thread::spawn(move || {
94 for mut result in preview_errored {
95 let error = result
96 .preview_error
97 .take()
98 .expect("preview_errored results must have preview_error set");
99 result.replace_result = Some(ReplaceResult::Error(error));
100 replacements_completed.fetch_add(1, Ordering::Relaxed);
101 on_completion(result);
102 }
103
104 let path_groups = group_results(included);
105
106 let num_threads = thread::available_parallelism()
107 .map(NonZero::get)
108 .unwrap_or(4)
109 .min(12);
110 let pool = rayon::ThreadPoolBuilder::new()
111 .num_threads(num_threads)
112 .build()
113 .unwrap();
114
115 pool.install(|| {
116 path_groups.into_par_iter().for_each(|(path, mut results)| {
117 if cancelled.load(Ordering::Relaxed) {
118 return;
119 }
120
121 if let Some(config) = &validation_search_config
122 && let Err(e) = validate_search_result_correctness(
123 config,
124 &results,
125 file_content_provider.as_ref(),
126 )
127 {
128 for res in &mut results {
129 res.replace_result =
130 Some(ReplaceResult::Error(format!("Validation failed: {e}")));
131 }
132 for result in results {
133 on_completion(result);
134 }
135 return;
136 }
137 if let Err(file_err) = replace_in_file(&mut results) {
138 for res in &mut results {
139 res.replace_result = Some(ReplaceResult::Error(file_err.to_string()));
140 }
141 }
142 if let Some(path) = path.as_ref() {
143 file_content_provider.invalidate(path);
144 }
145 replacements_completed.fetch_add(results.len(), Ordering::Relaxed);
146
147 for result in results {
148 on_completion(result);
149 }
150 });
151 });
152 });
153
154 num_ignored
155}
156
157fn validate_search_result_correctness(
158 validation_search_config: &FileSearcher,
159 results: &[SearchResultWithReplacement],
160 file_content_provider: &dyn FileContentProvider,
161) -> anyhow::Result<()> {
162 let Some(res) = results.first() else {
163 return Ok(());
164 };
165 let expected_path = res
166 .search_result
167 .path
168 .as_ref()
169 .ok_or_else(|| anyhow::anyhow!("Expected file path for validation"))?;
170
171 if !results
172 .iter()
173 .all(|r| r.search_result.path.as_ref() == Some(expected_path))
174 {
175 anyhow::bail!("Validation expects all results to share the same path");
176 }
177
178 let needs_context = validation_search_config.search().needs_haystack_context()
181 && results
182 .iter()
183 .any(|r| matches!(r.search_result.content, MatchContent::ByteRange { .. }));
184
185 let haystack = if needs_context {
188 Some(read_validation_haystack(
189 expected_path,
190 file_content_provider,
191 )?)
192 } else {
193 None
194 };
195
196 for res in results {
197 let expected = match &res.search_result.content {
198 MatchContent::Line { .. } => replace_all_if_match(
199 res.search_result.content.matched_text(),
200 validation_search_config.search(),
201 validation_search_config.replace(),
202 ),
203 MatchContent::ByteRange {
204 byte_start,
205 byte_end,
206 ..
207 } => {
208 let replacement = if let Some(haystack) = haystack.as_deref() {
209 replacement_for_match_in_haystack(
210 validation_search_config.search(),
211 validation_search_config.replace(),
212 haystack,
213 *byte_start,
214 *byte_end,
215 )
216 .ok_or_else(|| anyhow::anyhow!("Expected match at byte range for validation"))?
217 } else {
218 replacement_for_match(
219 res.search_result.content.matched_text(),
220 validation_search_config.search(),
221 validation_search_config.replace(),
222 )
223 };
224 Some(replacement)
225 }
226 };
227 let actual = &res.replacement;
228 anyhow::ensure!(
229 expected.as_ref() == Some(actual),
230 "Expected replacement does not match actual"
231 );
232 }
233 Ok(())
234}
235
236fn read_validation_haystack(
237 path: &Path,
238 file_content_provider: &dyn FileContentProvider,
239) -> anyhow::Result<Arc<String>> {
240 file_content_provider
241 .read_to_string(path)
242 .map_err(|e| anyhow::anyhow!("Failed to read file for replacement validation: {e}"))
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct ReplaceState {
247 pub num_successes: usize,
248 pub num_ignored: usize,
249 pub errors: Vec<SearchResultWithReplacement>,
250 pub replacement_errors_pos: usize,
251}
252
253impl ReplaceState {
254 #[allow(clippy::needless_pass_by_value)]
255 pub(crate) fn handle_command_results(&mut self, event: CommandResults) -> EventHandlingResult {
256 #[allow(clippy::match_same_arms)]
257 match event {
258 CommandResults::ScrollErrorsDown => {
259 self.scroll_replacement_errors_down();
260 EventHandlingResult::Rerender
261 }
262 CommandResults::ScrollErrorsUp => {
263 self.scroll_replacement_errors_up();
264 EventHandlingResult::Rerender
265 }
266 CommandResults::Quit => EventHandlingResult::Exit(None),
267 }
268 }
269
270 pub fn scroll_replacement_errors_up(&mut self) {
271 if self.replacement_errors_pos == 0 {
272 self.replacement_errors_pos = self.errors.len();
273 }
274 self.replacement_errors_pos = self.replacement_errors_pos.saturating_sub(1);
275 }
276
277 pub fn scroll_replacement_errors_down(&mut self) {
278 if self.replacement_errors_pos >= self.errors.len().saturating_sub(1) {
279 self.replacement_errors_pos = 0;
280 } else {
281 self.replacement_errors_pos += 1;
282 }
283 }
284}
285
286#[derive(Debug)]
287pub struct PerformingReplacementState {
288 pub processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
289 pub cancelled: Arc<AtomicBool>,
290 pub replacement_started: Instant,
291 pub num_replacements_completed: Arc<AtomicUsize>,
292 pub total_replacements: usize,
293}
294
295impl PerformingReplacementState {
296 pub fn new(
297 processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
298 cancelled: Arc<AtomicBool>,
299 num_replacements_completed: Arc<AtomicUsize>,
300 total_replacements: usize,
301 ) -> Self {
302 Self {
303 processing_receiver,
304 cancelled,
305 replacement_started: Instant::now(),
306 num_replacements_completed,
307 total_replacements,
308 }
309 }
310}
311
312pub fn perform_replacement(
313 search_results: Vec<SearchResultWithReplacement>,
314 background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
315 cancelled: Arc<AtomicBool>,
316 replacements_completed: Arc<AtomicUsize>,
317 event_sender: UnboundedSender<Event>,
318 validation_search_config: Option<FileSearcher>,
319 file_content_provider: Arc<dyn FileContentProvider>,
320) -> JoinHandle<()> {
321 tokio::spawn(async move {
322 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
323 let num_ignored = replace::spawn_replace_included(
324 search_results,
325 cancelled,
326 replacements_completed,
327 validation_search_config,
328 file_content_provider,
329 move |result| {
330 let _ = tx.send(result); },
332 );
333
334 let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); let mut replacement_results = Vec::new();
337 loop {
338 tokio::select! {
339 res = rx.recv() => match res {
340 Some(res) => replacement_results.push(res),
341 None => break,
342 },
343 _ = rerender_interval.tick() => {
344 let _ = event_sender.send(Event::Rerender);
345 }
346 }
347 }
348
349 let _ = event_sender.send(Event::Rerender);
350
351 let stats = crate::replace::calculate_statistics(replacement_results);
352 let _ = background_processing_sender.send(BackgroundProcessingEvent::ReplacementCompleted(
354 ReplaceState {
355 num_successes: stats.num_successes,
356 num_ignored,
357 errors: stats.errors,
358 replacement_errors_pos: 0,
359 },
360 ));
361 })
362}
363
364#[derive(Clone, Debug, PartialEq, Eq)]
365pub enum ReplaceResult {
366 Success,
367 Error(String),
368}
369
370fn mark_conflicting_replacements(results: &mut [SearchResultWithReplacement]) {
374 results.sort_by_key(|r| match &r.search_result.content {
376 MatchContent::ByteRange { byte_start, .. } => *byte_start,
377 MatchContent::Line { .. } => {
378 panic!(
379 "mark_conflicting_replacements called with Lines content - use only for byte-mode"
380 )
381 }
382 });
383
384 let mut last_end_byte: Option<usize> = None;
385
386 for result in results {
387 let MatchContent::ByteRange {
388 byte_start,
389 byte_end,
390 ..
391 } = &result.search_result.content
392 else {
393 panic!(
394 "mark_conflicting_replacements called with Lines content - use only for byte-mode"
395 )
396 };
397
398 if last_end_byte.is_some_and(|last_end| *byte_start < last_end) {
399 result.replace_result = Some(ReplaceResult::Error(
400 "Conflicts with previous replacement".to_owned(),
401 ));
402 } else {
403 last_end_byte = Some(*byte_end);
404 }
405 }
406}
407
408pub fn replace_in_file(results: &mut [SearchResultWithReplacement]) -> anyhow::Result<()> {
411 let file_path = match results {
412 [r, ..] => r.search_result.path.clone(),
413 [] => return Ok(()),
414 };
415 assert!(results.iter().all(|r| r.search_result.path == file_path));
416
417 let file_path = file_path.expect("File path must be present when searching in files");
418
419 match search::match_mode_of_results(results).expect("replace_in_file called with empty results")
420 {
421 MatchMode::Line => replace_line_mode(&file_path, results),
422 MatchMode::ByteRange => replace_byte_mode(&file_path, results),
423 }
424}
425
426fn replace_line_mode(
428 file_path: &Path,
429 results: &mut [SearchResultWithReplacement],
430) -> anyhow::Result<()> {
431 debug_assert!(
432 results.iter().all(|r| r.preview_error.is_none()),
433 "preview-errored results should not reach replace_line_mode"
434 );
435 let mut line_map: HashMap<usize, &mut SearchResultWithReplacement> = results
436 .iter_mut()
437 .map(|res| (res.search_result.start_line_number(), res))
438 .collect();
439
440 let parent_dir = file_path.parent().unwrap_or(Path::new("."));
441 let temp_output_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
442
443 {
444 let input = File::open(file_path)?;
445 let reader = BufReader::new(input);
446
447 let output = File::create(temp_output_file.path())?;
448 let mut writer = BufWriter::new(output);
449
450 for (idx, line_result) in reader.lines_with_endings().enumerate() {
451 let line_number = idx + 1;
452 let (mut line_bytes, line_ending) = line_result?;
453
454 if let Some(res) = line_map.get_mut(&line_number) {
455 let MatchContent::Line { content, .. } = &res.search_result.content else {
456 unreachable!("Line-mode must have Lines content")
457 };
458
459 if line_bytes == content.as_bytes() {
460 line_bytes = res.replacement.as_bytes().to_vec();
461 res.replace_result = Some(ReplaceResult::Success);
462 } else {
463 res.replace_result = Some(ReplaceResult::Error(
464 "File changed since last search".to_owned(),
465 ));
466 }
467 }
468
469 line_bytes.extend(line_ending.as_bytes());
470 writer.write_all(&line_bytes)?;
471 }
472
473 writer.flush()?;
474 }
475
476 temp_output_file.persist(file_path)?;
477 Ok(())
478}
479
480fn replace_byte_mode(
482 file_path: &Path,
483 results: &mut [SearchResultWithReplacement],
484) -> anyhow::Result<()> {
485 use std::io::Read;
486
487 debug_assert!(
488 results.iter().all(|r| r.preview_error.is_none()),
489 "preview-errored results should not reach replace_byte_mode"
490 );
491
492 mark_conflicting_replacements(results);
493
494 let mut to_replace: Vec<_> = results
495 .iter_mut()
496 .filter(|r| r.replace_result.is_none())
497 .collect();
498
499 if to_replace.is_empty() {
500 return Ok(());
501 }
502
503 to_replace.sort_by_key(|r| match &r.search_result.content {
504 MatchContent::ByteRange { byte_start, .. } => *byte_start,
505 MatchContent::Line { .. } => unreachable!(),
506 });
507
508 let parent_dir = file_path.parent().unwrap_or(Path::new("."));
509 let temp_output_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
510
511 {
512 let mut input = File::open(file_path)?;
513 let output = File::create(temp_output_file.path())?;
514 let mut writer = BufWriter::new(output);
515 let mut current_pos: usize = 0;
516
517 for result in to_replace {
518 let MatchContent::ByteRange {
519 byte_start,
520 byte_end,
521 content,
522 ..
523 } = &result.search_result.content
524 else {
525 unreachable!()
526 };
527
528 if *byte_start > current_pos {
530 let bytes_to_copy = byte_start - current_pos;
531 std::io::copy(
532 &mut Read::by_ref(&mut input).take(bytes_to_copy as u64),
533 &mut writer,
534 )?;
535 }
536
537 let match_len = byte_end - byte_start;
539 let mut actual_bytes = Vec::with_capacity(match_len);
540 let bytes_read = Read::by_ref(&mut input)
541 .take(match_len as u64)
542 .read_to_end(&mut actual_bytes)?;
543
544 if bytes_read < match_len {
545 writer.write_all(&actual_bytes)?;
548 break;
549 }
550
551 if actual_bytes != content.as_bytes() {
553 result.replace_result =
554 Some(ReplaceResult::Error("File changed since search".to_owned()));
555 writer.write_all(&actual_bytes)?;
556 } else {
557 result.replace_result = Some(ReplaceResult::Success);
558 writer.write_all(result.replacement.as_bytes())?;
559 }
560 current_pos = *byte_end;
561 }
562
563 std::io::copy(&mut input, &mut writer)?;
565 writer.flush()?;
566 }
567
568 temp_output_file.persist(file_path)?;
569 Ok(())
570}
571
572pub fn replace_all_in_file(
590 file_path: &Path,
591 search: &SearchType,
592 replace: &str,
593 multiline: bool,
594) -> anyhow::Result<bool> {
595 if multiline {
596 return replace_in_memory(file_path, search, replace);
597 }
598
599 replace_line_by_line(file_path, search, replace)
600}
601
602pub fn add_replacement(
603 search_result: SearchResult,
604 search: &SearchType,
605 replace: &str,
606) -> Option<SearchResultWithReplacement> {
607 add_replacement_with_haystack(search_result, search, replace, None)
608}
609
610pub fn add_replacement_with_haystack(
611 search_result: SearchResult,
612 search: &SearchType,
613 replace: &str,
614 haystack: Option<&str>,
615) -> Option<SearchResultWithReplacement> {
616 let replacement = match &search_result.content {
617 MatchContent::Line { .. } => {
618 replace_all_if_match(search_result.content.matched_text(), search, replace)?
619 }
620 MatchContent::ByteRange {
621 byte_start,
622 byte_end,
623 ..
624 } => {
625 if let Some(haystack) = haystack {
626 replacement_for_match_in_haystack(search, replace, haystack, *byte_start, *byte_end)
627 .unwrap_or_else(|| {
628 replacement_for_match(search_result.content.matched_text(), search, replace)
629 })
630 } else {
631 replacement_for_match(search_result.content.matched_text(), search, replace)
632 }
633 }
634 };
635 Some(SearchResultWithReplacement {
636 search_result,
637 replacement,
638 replace_result: None,
639 preview_error: None,
640 })
641}
642
643fn replace_line_by_line(
644 file_path: &Path,
645 search: &SearchType,
646 replace: &str,
647) -> anyhow::Result<bool> {
648 let search_results = search::search_file(file_path, search, false)?;
649 if !search_results.is_empty() {
650 let mut replacement_results = search_results
651 .into_iter()
652 .map(|r| {
653 add_replacement(r, search, replace).unwrap_or_else(|| {
654 panic!("Called add_replacement with non-matching search result")
655 })
656 })
657 .collect::<Vec<_>>();
658 replace_in_file(&mut replacement_results)?;
659 return Ok(true);
660 }
661
662 Ok(false)
663}
664
665fn replace_in_memory(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
666 let content = fs::read_to_string(file_path).with_context(|| {
667 format!(
668 "Failed to read file as UTF-8 for in-memory replacement: {}",
669 file_path.display()
670 )
671 })?;
672 if let Some(new_content) = replace_all_if_match(&content, search, replace) {
673 let parent_dir = file_path.parent().unwrap_or(Path::new("."));
674 let mut temp_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
675 temp_file.write_all(new_content.as_bytes())?;
676 temp_file.persist(file_path)?;
677 Ok(true)
678 } else {
679 Ok(false)
680 }
681}
682
683pub fn replace_all_if_match(line: &str, search: &SearchType, replace: &str) -> Option<String> {
701 if line.is_empty() || search.is_empty() {
702 return None;
703 }
704
705 if search::contains_search(line, search) {
706 let replacement = match search {
707 SearchType::Fixed(fixed_str) => line.replace(fixed_str, replace),
708 SearchType::Pattern(pattern) => pattern.replace_all(line, replace).to_string(),
709 SearchType::PatternAdvanced(pattern) => pattern.replace_all(line, replace).to_string(),
710 };
711 Some(replacement)
712 } else {
713 None
714 }
715}
716
717pub fn replacement_for_match(matched_text: &str, search: &SearchType, replace: &str) -> String {
736 match search {
737 SearchType::Fixed(_) => replace.to_string(),
738 SearchType::Pattern(pattern) => pattern.replace(matched_text, replace).to_string(),
739 SearchType::PatternAdvanced(pattern) => pattern.replace(matched_text, replace).to_string(),
740 }
741}
742
743pub fn replacement_for_match_in_haystack(
750 search: &SearchType,
751 replace: &str,
752 haystack: &str,
753 byte_start: usize,
754 byte_end: usize,
755) -> Option<String> {
756 let slice = haystack.get(byte_start..byte_end)?;
757
758 match search {
759 SearchType::Fixed(fixed_str) => {
760 if slice != fixed_str {
761 return None;
762 }
763 Some(replace.to_string())
764 }
765 SearchType::Pattern(pattern) => pattern.captures_iter(haystack).find_map(|caps| {
766 let mat = caps.get(0)?;
767 if mat.start() == byte_start && mat.end() == byte_end {
768 let mut out = String::new();
769 caps.expand(replace, &mut out);
770 Some(out)
771 } else {
772 None
773 }
774 }),
775 SearchType::PatternAdvanced(pattern) => {
776 pattern.captures_iter(haystack).flatten().find_map(|caps| {
777 let mat = caps.get(0)?;
778 if mat.start() == byte_start && mat.end() == byte_end {
779 let mut out = String::new();
780 caps.expand(replace, &mut out);
781 Some(out)
782 } else {
783 None
784 }
785 })
786 }
787 }
788}
789
790pub fn interpret_escapes(s: &str) -> String {
800 let mut result = String::with_capacity(s.len());
801 let mut chars = s.chars().peekable();
802
803 while let Some(c) = chars.next() {
804 if c == '\\' {
805 match chars.peek() {
806 Some('n') => {
807 chars.next();
808 result.push('\n');
809 }
810 Some('r') => {
811 chars.next();
812 result.push('\r');
813 }
814 Some('t') => {
815 chars.next();
816 result.push('\t');
817 }
818 Some('\\') => {
819 chars.next();
820 result.push('\\');
821 }
822 _ => {
823 result.push('\\');
825 }
826 }
827 } else {
828 result.push(c);
829 }
830 }
831
832 result
833}
834
835#[derive(Clone, Debug, Eq, PartialEq)]
836pub struct ReplaceStats {
837 pub num_successes: usize,
838 pub errors: Vec<SearchResultWithReplacement>,
839}
840
841pub fn calculate_statistics<I>(results: I) -> ReplaceStats
842where
843 I: IntoIterator<Item = SearchResultWithReplacement>,
844{
845 let mut num_successes = 0;
846 let mut errors = vec![];
847
848 results.into_iter().for_each(|mut res| {
849 assert!(
850 res.search_result.included,
851 "Expected only included results, found {res:?}"
852 );
853 debug_assert!(
854 res.preview_error.is_none(),
855 "preview_error should have been moved to replace_result before reaching calculate_statistics: {res:?}"
856 );
857 match &res.replace_result {
858 Some(ReplaceResult::Success) => {
859 num_successes += 1;
860 }
861 None => {
862 res.replace_result = Some(ReplaceResult::Error(
863 "Failed to find search result in file".to_owned(),
864 ));
865 errors.push(res);
866 }
867 Some(ReplaceResult::Error(_)) => {
868 errors.push(res);
869 }
870 }
871 });
872
873 ReplaceStats {
874 num_successes,
875 errors,
876 }
877}
878
879#[cfg(test)]
880mod tests {
881 use std::{
882 io::Write,
883 path::{Path, PathBuf},
884 };
885
886 use regex::Regex;
887 use tempfile::{NamedTempFile, TempDir};
888
889 use crate::{
890 line_reader::LineEnding,
891 replace::{
892 ReplaceResult, add_replacement, replace_all_if_match, replace_all_in_file,
893 replace_in_file, replace_in_memory, replace_line_by_line,
894 },
895 search::{
896 MatchContent, SearchResult, SearchResultWithReplacement, SearchType, search_file,
897 },
898 };
899
900 use crate::{
901 app::EventHandlingResult,
902 commands::CommandResults,
903 replace::{self, ReplaceState},
904 };
905
906 use super::{interpret_escapes, replacement_for_match_in_haystack};
907
908 fn line_content(result: &SearchResult) -> (&str, LineEnding) {
909 match &result.content {
910 MatchContent::Line {
911 content,
912 line_ending,
913 ..
914 } => (content, *line_ending),
915 MatchContent::ByteRange { .. } => panic!("Expected Lines content"),
916 }
917 }
918
919 fn byte_range_content(result: &SearchResult) -> &str {
920 match &result.content {
921 MatchContent::ByteRange { content, .. } => content,
922 MatchContent::Line { .. } => panic!("Expected ByteRange"),
923 }
924 }
925
926 fn byte_range_bytes(result: &SearchResult) -> (usize, usize) {
927 match &result.content {
928 MatchContent::ByteRange {
929 byte_start,
930 byte_end,
931 ..
932 } => (*byte_start, *byte_end),
933 MatchContent::Line { .. } => panic!("Expected ByteRange"),
934 }
935 }
936
937 mod interpret_escapes_tests {
938 use super::*;
939
940 #[test]
941 fn test_newline() {
942 assert_eq!(interpret_escapes(r"\n"), "\n");
943 assert_eq!(interpret_escapes(r"foo\nbar"), "foo\nbar");
944 assert_eq!(interpret_escapes(r"\n\n"), "\n\n");
945 }
946
947 #[test]
948 fn test_tab() {
949 assert_eq!(interpret_escapes(r"\t"), "\t");
950 assert_eq!(interpret_escapes(r"foo\tbar"), "foo\tbar");
951 }
952
953 #[test]
954 fn test_carriage_return() {
955 assert_eq!(interpret_escapes(r"\r"), "\r");
956 assert_eq!(interpret_escapes(r"\r\n"), "\r\n");
957 }
958
959 #[test]
960 fn test_backslash() {
961 assert_eq!(interpret_escapes(r"\\"), "\\");
962 assert_eq!(interpret_escapes(r"\\n"), "\\n");
963 assert_eq!(interpret_escapes(r"foo\\bar"), "foo\\bar");
964 }
965
966 #[test]
967 fn test_unrecognized_escapes_left_as_is() {
968 assert_eq!(interpret_escapes(r"\x"), "\\x");
969 assert_eq!(interpret_escapes(r"\a"), "\\a");
970 assert_eq!(interpret_escapes(r"\u0041"), "\\u0041");
971 }
972
973 #[test]
974 fn test_trailing_backslash() {
975 assert_eq!(interpret_escapes(r"foo\"), "foo\\");
976 }
977
978 #[test]
979 fn test_no_escapes() {
980 assert_eq!(interpret_escapes("hello world"), "hello world");
981 assert_eq!(interpret_escapes(""), "");
982 }
983
984 #[test]
985 fn test_mixed() {
986 assert_eq!(
987 interpret_escapes(r"line1\nline2\ttab\\slash"),
988 "line1\nline2\ttab\\slash"
989 );
990 }
991 }
992
993 mod replacement_for_match_in_haystack_tests {
994 use super::*;
995 use fancy_regex::Regex as FancyRegex;
996 use regex::Regex;
997
998 #[test]
999 fn test_fixed_string_match() {
1000 let haystack = "foo";
1001 let search = SearchType::Fixed("foo".to_string());
1002 let replacement =
1003 replacement_for_match_in_haystack(&search, "bar", haystack, 0, 3).unwrap();
1004 assert_eq!(replacement, "bar");
1005 }
1006
1007 #[test]
1008 fn test_fixed_string_mismatch() {
1009 let haystack = "foo";
1010 let search = SearchType::Fixed("foo".to_string());
1011 assert!(replacement_for_match_in_haystack(&search, "bar", haystack, 0, 2).is_none());
1012 }
1013
1014 #[test]
1015 fn test_regex_match() {
1016 let haystack = "abc123";
1017 let search = SearchType::Pattern(Regex::new(r"\d+").unwrap());
1018 let replacement =
1019 replacement_for_match_in_haystack(&search, "NUM", haystack, 3, 6).unwrap();
1020 assert_eq!(replacement, "NUM");
1021 }
1022
1023 #[test]
1024 fn test_regex_match_with_capture_groups() {
1025 let haystack = "abc123def";
1026 let search = SearchType::Pattern(Regex::new(r"(\d+)").unwrap());
1027 let replacement =
1028 replacement_for_match_in_haystack(&search, "NUM-$1", haystack, 3, 6).unwrap();
1029 assert_eq!(replacement, "NUM-123");
1030 }
1031
1032 #[test]
1033 fn test_advanced_regex_lookaround_match() {
1034 let haystack = "start\nmiddle\nend\n";
1035 let search = SearchType::PatternAdvanced(
1036 FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
1037 );
1038 let start = haystack.find("middle").unwrap();
1039 let end = start + "middle".len();
1040 let replacement =
1041 replacement_for_match_in_haystack(&search, "REPLACED", haystack, start, end)
1042 .unwrap();
1043 assert_eq!(replacement, "REPLACED");
1044 }
1045
1046 #[test]
1047 fn test_advanced_regex_lookaround_with_capture_groups() {
1048 let haystack = "foo-123-bar";
1049 let search =
1050 SearchType::PatternAdvanced(FancyRegex::new(r"(?<=foo-)(\d+)(?=-bar)").unwrap());
1051 let start = haystack.find("123").unwrap();
1052 let end = start + "123".len();
1053 let replacement =
1054 replacement_for_match_in_haystack(&search, "ID:$1", haystack, start, end).unwrap();
1055 assert_eq!(replacement, "ID:123");
1056 }
1057 }
1058
1059 mod validate_search_result_correctness_tests {
1060 use super::super::validate_search_result_correctness;
1061 use crate::file_content::FileContentProvider;
1062 use crate::line_reader::LineEnding;
1063 use crate::search::{
1064 ByteRangeParams, FileSearcher, Line, ParsedDirConfig, ParsedSearchConfig, SearchResult,
1065 SearchResultWithReplacement, SearchType,
1066 };
1067 use fancy_regex::Regex as FancyRegex;
1068 use ignore::overrides::Override;
1069 use std::path::{Path, PathBuf};
1070 use std::sync::Arc;
1071
1072 struct TestFileContentProvider {
1073 contents: Arc<String>,
1074 fail: bool,
1075 }
1076
1077 impl FileContentProvider for TestFileContentProvider {
1078 fn read_to_string(&self, _path: &Path) -> anyhow::Result<Arc<String>> {
1079 if self.fail {
1080 Err(anyhow::anyhow!("boom"))
1081 } else {
1082 Ok(Arc::clone(&self.contents))
1083 }
1084 }
1085 }
1086
1087 fn build_searcher(search: SearchType, replace: &str) -> FileSearcher {
1088 let search_config = ParsedSearchConfig {
1089 search,
1090 replace: replace.to_string(),
1091 multiline: true,
1092 };
1093 let dir_config = ParsedDirConfig {
1094 overrides: Override::empty(),
1095 root_dir: PathBuf::from("."),
1096 include_hidden: false,
1097 };
1098 FileSearcher::new(search_config, dir_config)
1099 }
1100
1101 fn build_result(
1102 path: &Path,
1103 byte_start: usize,
1104 byte_end: usize,
1105 matched: &str,
1106 replacement: &str,
1107 ) -> SearchResultWithReplacement {
1108 let line = Line {
1109 content: matched.to_string(),
1110 line_ending: LineEnding::Lf,
1111 };
1112 let search_result = SearchResult::new_byte_range(ByteRangeParams {
1113 path: Some(path.to_path_buf()),
1114 lines: vec![(2, line)],
1115 match_start_in_first_line: 0,
1116 match_end_in_last_line: matched.len(),
1117 byte_start,
1118 byte_end,
1119 content: matched.to_string(),
1120 included: true,
1121 });
1122 SearchResultWithReplacement {
1123 search_result,
1124 replacement: replacement.to_string(),
1125 replace_result: None,
1126 preview_error: None,
1127 }
1128 }
1129
1130 #[test]
1131 fn test_validate_search_result_correctness_advanced_regex_uses_haystack()
1132 -> anyhow::Result<()> {
1133 let haystack = "start\nmiddle\nend\n";
1134 let search = SearchType::PatternAdvanced(
1135 FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
1136 );
1137 let replace = "REPLACED";
1138 let searcher = build_searcher(search, replace);
1139 let start = haystack.find("middle").unwrap();
1140 let end = start + "middle".len();
1141 let path = PathBuf::from("file.txt");
1142 let result = build_result(path.as_path(), start, end, "middle", replace);
1143 let provider = TestFileContentProvider {
1144 contents: Arc::new(haystack.to_string()),
1145 fail: false,
1146 };
1147
1148 validate_search_result_correctness(&searcher, &[result], &provider)?;
1149 Ok(())
1150 }
1151
1152 #[test]
1153 fn test_validate_search_result_correctness_returns_error_on_read_failure() {
1154 let haystack = "start\nmiddle\nend\n";
1155 let search = SearchType::PatternAdvanced(
1156 FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
1157 );
1158 let replace = "REPLACED";
1159 let searcher = build_searcher(search, replace);
1160 let start = haystack.find("middle").unwrap();
1161 let end = start + "middle".len();
1162 let path = PathBuf::from("file.txt");
1163 let result = build_result(path.as_path(), start, end, "middle", replace);
1164 let provider = TestFileContentProvider {
1165 contents: Arc::new(haystack.to_string()),
1166 fail: true,
1167 };
1168
1169 let err = validate_search_result_correctness(&searcher, &[result], &provider);
1170 assert!(err.is_err());
1171 assert!(
1172 err.unwrap_err()
1173 .to_string()
1174 .contains("Failed to read file for replacement validation")
1175 );
1176 }
1177
1178 #[test]
1179 fn test_validate_search_result_correctness_returns_error_on_missing_match() {
1180 let haystack = "start\nmiddle\nend\n";
1181 let search = SearchType::PatternAdvanced(
1182 FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
1183 );
1184 let replace = "REPLACED";
1185 let searcher = build_searcher(search, replace);
1186 let start = haystack.find("middle").unwrap();
1187 let end = start + "middle".len();
1188 let path = PathBuf::from("file.txt");
1189 let result = build_result(path.as_path(), start + 1, end + 1, "middle", replace);
1190 let provider = TestFileContentProvider {
1191 contents: Arc::new(haystack.to_string()),
1192 fail: false,
1193 };
1194
1195 let err = validate_search_result_correctness(&searcher, &[result], &provider);
1196 assert!(err.is_err());
1197 assert!(
1198 err.unwrap_err()
1199 .to_string()
1200 .contains("Expected match at byte range for validation")
1201 );
1202 }
1203 }
1204
1205 fn create_search_result_with_replacement(
1206 path: &str,
1207 line_number: usize,
1208 line: &str,
1209 line_ending: LineEnding,
1210 replacement: &str,
1211 included: bool,
1212 replace_result: Option<ReplaceResult>,
1213 ) -> SearchResultWithReplacement {
1214 SearchResultWithReplacement {
1216 search_result: SearchResult::new_line(
1217 Some(PathBuf::from(path)),
1218 line_number,
1219 line.to_string(),
1220 line_ending,
1221 included,
1222 ),
1223 replacement: replacement.to_string(),
1224 replace_result,
1225 preview_error: None,
1226 }
1227 }
1228
1229 #[test]
1230 fn test_split_results_all_included() {
1231 let result1 = create_search_result_with_replacement(
1232 "file1.txt",
1233 1,
1234 "line1",
1235 LineEnding::Lf,
1236 "repl1",
1237 true,
1238 None,
1239 );
1240 let result2 = create_search_result_with_replacement(
1241 "file2.txt",
1242 2,
1243 "line2",
1244 LineEnding::Lf,
1245 "repl2",
1246 true,
1247 None,
1248 );
1249 let result3 = create_search_result_with_replacement(
1250 "file3.txt",
1251 3,
1252 "line3",
1253 LineEnding::Lf,
1254 "repl3",
1255 true,
1256 None,
1257 );
1258
1259 let search_results = vec![result1.clone(), result2.clone(), result3.clone()];
1260
1261 let (included, preview_errored, num_ignored) = replace::split_results(search_results);
1262 assert_eq!(num_ignored, 0);
1263 assert!(preview_errored.is_empty());
1264 assert_eq!(included, vec![result1, result2, result3]);
1265 }
1266
1267 #[test]
1268 fn test_split_results_mixed() {
1269 let result1 = create_search_result_with_replacement(
1270 "file1.txt",
1271 1,
1272 "line1",
1273 LineEnding::Lf,
1274 "repl1",
1275 true,
1276 None,
1277 );
1278 let result2 = create_search_result_with_replacement(
1279 "file2.txt",
1280 2,
1281 "line2",
1282 LineEnding::Lf,
1283 "repl2",
1284 false,
1285 None,
1286 );
1287 let result3 = create_search_result_with_replacement(
1288 "file3.txt",
1289 3,
1290 "line3",
1291 LineEnding::Lf,
1292 "repl3",
1293 true,
1294 None,
1295 );
1296 let result4 = create_search_result_with_replacement(
1297 "file4.txt",
1298 4,
1299 "line4",
1300 LineEnding::Lf,
1301 "repl4",
1302 false,
1303 None,
1304 );
1305
1306 let search_results = vec![result1.clone(), result2, result3.clone(), result4];
1307
1308 let (included, preview_errored, num_ignored) = replace::split_results(search_results);
1309 assert_eq!(num_ignored, 2);
1310 assert!(preview_errored.is_empty());
1311 assert_eq!(included, vec![result1, result3]);
1312 assert!(included.iter().all(|r| r.search_result.included));
1313 }
1314
1315 #[test]
1316 fn test_split_results_separates_preview_errors() {
1317 let mut normal = create_search_result_with_replacement(
1318 "file1.txt",
1319 1,
1320 "line1",
1321 LineEnding::Lf,
1322 "repl1",
1323 true,
1324 None,
1325 );
1326 normal.preview_error = None;
1327
1328 let mut errored = create_search_result_with_replacement(
1329 "file2.txt",
1330 2,
1331 "line2",
1332 LineEnding::Lf,
1333 "",
1334 true,
1335 None,
1336 );
1337 errored.preview_error = Some("file unreadable".to_string());
1338
1339 let excluded = create_search_result_with_replacement(
1340 "file3.txt",
1341 3,
1342 "line3",
1343 LineEnding::Lf,
1344 "repl3",
1345 false,
1346 None,
1347 );
1348
1349 let search_results = vec![normal.clone(), errored.clone(), excluded];
1350
1351 let (included, preview_errored, num_ignored) = replace::split_results(search_results);
1352 assert_eq!(num_ignored, 1);
1353 assert_eq!(included, vec![normal]);
1354 assert_eq!(preview_errored, vec![errored]);
1355 }
1356
1357 #[test]
1358 fn test_replace_state_scroll_replacement_errors_up() {
1359 let mut state = ReplaceState {
1360 num_successes: 5,
1361 num_ignored: 2,
1362 errors: vec![
1363 create_search_result_with_replacement(
1364 "file1.txt",
1365 1,
1366 "error1",
1367 LineEnding::Lf,
1368 "repl1",
1369 true,
1370 Some(ReplaceResult::Error("err1".to_string())),
1371 ),
1372 create_search_result_with_replacement(
1373 "file2.txt",
1374 2,
1375 "error2",
1376 LineEnding::Lf,
1377 "repl2",
1378 true,
1379 Some(ReplaceResult::Error("err2".to_string())),
1380 ),
1381 create_search_result_with_replacement(
1382 "file3.txt",
1383 3,
1384 "error3",
1385 LineEnding::Lf,
1386 "repl3",
1387 true,
1388 Some(ReplaceResult::Error("err3".to_string())),
1389 ),
1390 ],
1391 replacement_errors_pos: 1,
1392 };
1393
1394 state.scroll_replacement_errors_up();
1395 assert_eq!(state.replacement_errors_pos, 0);
1396
1397 state.scroll_replacement_errors_up();
1398 assert_eq!(state.replacement_errors_pos, 2);
1399
1400 state.scroll_replacement_errors_up();
1401 assert_eq!(state.replacement_errors_pos, 1);
1402 }
1403
1404 #[test]
1405 fn test_replace_state_scroll_replacement_errors_down() {
1406 let mut state = ReplaceState {
1407 num_successes: 5,
1408 num_ignored: 2,
1409 errors: vec![
1410 create_search_result_with_replacement(
1411 "file1.txt",
1412 1,
1413 "error1",
1414 LineEnding::Lf,
1415 "repl1",
1416 true,
1417 Some(ReplaceResult::Error("err1".to_string())),
1418 ),
1419 create_search_result_with_replacement(
1420 "file2.txt",
1421 2,
1422 "error2",
1423 LineEnding::Lf,
1424 "repl2",
1425 true,
1426 Some(ReplaceResult::Error("err2".to_string())),
1427 ),
1428 create_search_result_with_replacement(
1429 "file3.txt",
1430 3,
1431 "error3",
1432 LineEnding::Lf,
1433 "repl3",
1434 true,
1435 Some(ReplaceResult::Error("err3".to_string())),
1436 ),
1437 ],
1438 replacement_errors_pos: 1,
1439 };
1440
1441 state.scroll_replacement_errors_down();
1442 assert_eq!(state.replacement_errors_pos, 2);
1443
1444 state.scroll_replacement_errors_down();
1445 assert_eq!(state.replacement_errors_pos, 0);
1446
1447 state.scroll_replacement_errors_down();
1448 assert_eq!(state.replacement_errors_pos, 1);
1449 }
1450
1451 #[test]
1452 fn test_replace_state_handle_command_results() {
1453 let mut state = ReplaceState {
1454 num_successes: 5,
1455 num_ignored: 2,
1456 errors: vec![
1457 create_search_result_with_replacement(
1458 "file1.txt",
1459 1,
1460 "error1",
1461 LineEnding::Lf,
1462 "repl1",
1463 true,
1464 Some(ReplaceResult::Error("err1".to_string())),
1465 ),
1466 create_search_result_with_replacement(
1467 "file2.txt",
1468 2,
1469 "error2",
1470 LineEnding::Lf,
1471 "repl2",
1472 true,
1473 Some(ReplaceResult::Error("err2".to_string())),
1474 ),
1475 ],
1476 replacement_errors_pos: 0,
1477 };
1478
1479 let result = state.handle_command_results(CommandResults::ScrollErrorsDown);
1480 assert!(matches!(result, EventHandlingResult::Rerender));
1481 assert_eq!(state.replacement_errors_pos, 1);
1482
1483 let result = state.handle_command_results(CommandResults::ScrollErrorsUp);
1484 assert!(matches!(result, EventHandlingResult::Rerender));
1485 assert_eq!(state.replacement_errors_pos, 0);
1486
1487 let result = state.handle_command_results(CommandResults::Quit);
1488 assert!(matches!(result, EventHandlingResult::Exit(None)));
1489 }
1490
1491 #[test]
1492 fn test_calculate_statistics_all_success() {
1493 let results = vec![
1494 create_search_result_with_replacement(
1495 "file1.txt",
1496 1,
1497 "line1",
1498 LineEnding::Lf,
1499 "repl1",
1500 true,
1501 Some(ReplaceResult::Success),
1502 ),
1503 create_search_result_with_replacement(
1504 "file2.txt",
1505 2,
1506 "line2",
1507 LineEnding::Lf,
1508 "repl2",
1509 true,
1510 Some(ReplaceResult::Success),
1511 ),
1512 create_search_result_with_replacement(
1513 "file3.txt",
1514 3,
1515 "line3",
1516 LineEnding::Lf,
1517 "repl3",
1518 true,
1519 Some(ReplaceResult::Success),
1520 ),
1521 ];
1522
1523 let stats = crate::replace::calculate_statistics(results);
1524 assert_eq!(stats.num_successes, 3);
1525 assert_eq!(stats.errors.len(), 0);
1526 }
1527
1528 #[test]
1529 fn test_calculate_statistics_with_errors() {
1530 let error_result = create_search_result_with_replacement(
1531 "file2.txt",
1532 2,
1533 "line2",
1534 LineEnding::Lf,
1535 "repl2",
1536 true,
1537 Some(ReplaceResult::Error("test error".to_string())),
1538 );
1539 let results = vec![
1540 create_search_result_with_replacement(
1541 "file1.txt",
1542 1,
1543 "line1",
1544 LineEnding::Lf,
1545 "repl1",
1546 true,
1547 Some(ReplaceResult::Success),
1548 ),
1549 error_result.clone(),
1550 create_search_result_with_replacement(
1551 "file3.txt",
1552 3,
1553 "line3",
1554 LineEnding::Lf,
1555 "repl3",
1556 true,
1557 Some(ReplaceResult::Success),
1558 ),
1559 ];
1560
1561 let stats = crate::replace::calculate_statistics(results);
1562 assert_eq!(stats.num_successes, 2);
1563 assert_eq!(stats.errors.len(), 1);
1564 assert_eq!(
1565 stats.errors[0].search_result.path,
1566 error_result.search_result.path
1567 );
1568 }
1569
1570 #[test]
1571 fn test_calculate_statistics_with_none_results() {
1572 let results = vec![
1573 create_search_result_with_replacement(
1574 "file1.txt",
1575 1,
1576 "line1",
1577 LineEnding::Lf,
1578 "repl1",
1579 true,
1580 Some(ReplaceResult::Success),
1581 ),
1582 create_search_result_with_replacement(
1583 "file2.txt",
1584 2,
1585 "line2",
1586 LineEnding::Lf,
1587 "repl2",
1588 true,
1589 None,
1590 ), create_search_result_with_replacement(
1592 "file3.txt",
1593 3,
1594 "line3",
1595 LineEnding::Lf,
1596 "repl3",
1597 true,
1598 Some(ReplaceResult::Success),
1599 ),
1600 ];
1601
1602 let stats = crate::replace::calculate_statistics(results);
1603 assert_eq!(stats.num_successes, 2);
1604 assert_eq!(stats.errors.len(), 1);
1605 assert_eq!(
1606 stats.errors[0].search_result.path,
1607 Some(PathBuf::from("file2.txt"))
1608 );
1609 assert_eq!(
1610 stats.errors[0].replace_result,
1611 Some(ReplaceResult::Error(
1612 "Failed to find search result in file".to_owned()
1613 ))
1614 );
1615 }
1616
1617 #[test]
1618 fn test_calculate_statistics_with_preview_error_converted() {
1619 let mut preview_errored = create_search_result_with_replacement(
1620 "file1.txt",
1621 1,
1622 "line1",
1623 LineEnding::Lf,
1624 "",
1625 true,
1626 None,
1627 );
1628 preview_errored.preview_error = Some("file unreadable".to_string());
1629
1630 let error = preview_errored.preview_error.take().unwrap();
1632 preview_errored.replace_result = Some(ReplaceResult::Error(error));
1633
1634 let success = create_search_result_with_replacement(
1635 "file2.txt",
1636 2,
1637 "line2",
1638 LineEnding::Lf,
1639 "repl2",
1640 true,
1641 Some(ReplaceResult::Success),
1642 );
1643
1644 let stats = crate::replace::calculate_statistics(vec![preview_errored, success]);
1645 assert_eq!(stats.num_successes, 1);
1646 assert_eq!(stats.errors.len(), 1);
1647 assert_eq!(
1648 stats.errors[0].replace_result,
1649 Some(ReplaceResult::Error("file unreadable".to_string()))
1650 );
1651 }
1652
1653 mod test_helpers {
1654 use crate::search::SearchType;
1655
1656 pub fn create_fixed_search(term: &str) -> SearchType {
1657 SearchType::Fixed(term.to_string())
1658 }
1659 }
1660
1661 fn create_test_file(temp_dir: &TempDir, name: &str, content: &str) -> PathBuf {
1662 let file_path = temp_dir.path().join(name);
1663 std::fs::write(&file_path, content).unwrap();
1664 file_path
1665 }
1666
1667 fn assert_file_content(file_path: &Path, expected_content: &str) {
1668 let content = std::fs::read_to_string(file_path).unwrap();
1669 assert_eq!(content, expected_content);
1670 }
1671
1672 fn fixed_search(pattern: &str) -> SearchType {
1673 SearchType::Fixed(pattern.to_string())
1674 }
1675
1676 fn regex_search(pattern: &str) -> SearchType {
1677 SearchType::Pattern(Regex::new(pattern).unwrap())
1678 }
1679
1680 #[test]
1682 fn test_replace_in_file_success() {
1683 let temp_dir = TempDir::new().unwrap();
1684 let file_path = create_test_file(
1685 &temp_dir,
1686 "test.txt",
1687 "line 1\nold text\nline 3\nold text\nline 5\n",
1688 );
1689
1690 let mut results = vec![
1692 create_search_result_with_replacement(
1693 file_path.to_str().unwrap(),
1694 2,
1695 "old text",
1696 LineEnding::Lf,
1697 "new text",
1698 true,
1699 None,
1700 ),
1701 create_search_result_with_replacement(
1702 file_path.to_str().unwrap(),
1703 4,
1704 "old text",
1705 LineEnding::Lf,
1706 "new text",
1707 true,
1708 None,
1709 ),
1710 ];
1711
1712 let result = replace_in_file(&mut results);
1714 assert!(result.is_ok());
1715
1716 assert_eq!(results.len(), 2);
1718 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
1719 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
1720
1721 assert_file_content(&file_path, "line 1\nnew text\nline 3\nnew text\nline 5\n");
1723 }
1724
1725 #[test]
1726 fn test_replace_in_file_success_no_final_newline() {
1727 let temp_dir = TempDir::new().unwrap();
1728 let file_path = create_test_file(
1729 &temp_dir,
1730 "test.txt",
1731 "line 1\nold text\nline 3\nold text\nline 5",
1732 );
1733
1734 let mut results = vec![
1736 create_search_result_with_replacement(
1737 file_path.to_str().unwrap(),
1738 2,
1739 "old text",
1740 LineEnding::Lf,
1741 "new text",
1742 true,
1743 None,
1744 ),
1745 create_search_result_with_replacement(
1746 file_path.to_str().unwrap(),
1747 4,
1748 "old text",
1749 LineEnding::Lf,
1750 "new text",
1751 true,
1752 None,
1753 ),
1754 ];
1755
1756 let result = replace_in_file(&mut results);
1758 assert!(result.is_ok());
1759
1760 assert_eq!(results.len(), 2);
1762 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
1763 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
1764
1765 let new_content = std::fs::read_to_string(&file_path).unwrap();
1767 assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5");
1768 }
1769
1770 #[test]
1771 fn test_replace_in_file_success_windows_newlines() {
1772 let temp_dir = TempDir::new().unwrap();
1773 let file_path = create_test_file(
1774 &temp_dir,
1775 "test.txt",
1776 "line 1\r\nold text\r\nline 3\r\nold text\r\nline 5\r\n",
1777 );
1778
1779 let mut results = vec![
1781 create_search_result_with_replacement(
1782 file_path.to_str().unwrap(),
1783 2,
1784 "old text",
1785 LineEnding::CrLf,
1786 "new text",
1787 true,
1788 None,
1789 ),
1790 create_search_result_with_replacement(
1791 file_path.to_str().unwrap(),
1792 4,
1793 "old text",
1794 LineEnding::CrLf,
1795 "new text",
1796 true,
1797 None,
1798 ),
1799 ];
1800
1801 let result = replace_in_file(&mut results);
1803 assert!(result.is_ok());
1804
1805 assert_eq!(results.len(), 2);
1807 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
1808 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
1809
1810 let new_content = std::fs::read_to_string(&file_path).unwrap();
1812 assert_eq!(
1813 new_content,
1814 "line 1\r\nnew text\r\nline 3\r\nnew text\r\nline 5\r\n"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_replace_in_file_success_mixed_newlines() {
1820 let temp_dir = TempDir::new().unwrap();
1821 let file_path = create_test_file(
1822 &temp_dir,
1823 "test.txt",
1824 "\n\r\nline 1\nold text\r\nline 3\nline 4\r\nline 5\r\n\n\n",
1825 );
1826
1827 let mut results = vec![
1829 create_search_result_with_replacement(
1830 file_path.to_str().unwrap(),
1831 4,
1832 "old text",
1833 LineEnding::CrLf,
1834 "new text",
1835 true,
1836 None,
1837 ),
1838 create_search_result_with_replacement(
1839 file_path.to_str().unwrap(),
1840 7,
1841 "line 5",
1842 LineEnding::CrLf,
1843 "updated line 5",
1844 true,
1845 None,
1846 ),
1847 ];
1848
1849 let result = replace_in_file(&mut results);
1851 assert!(result.is_ok());
1852
1853 assert_eq!(results.len(), 2);
1855 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
1856 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
1857
1858 let new_content = std::fs::read_to_string(&file_path).unwrap();
1860 assert_eq!(
1861 new_content,
1862 "\n\r\nline 1\nnew text\r\nline 3\nline 4\r\nupdated line 5\r\n\n\n"
1863 );
1864 }
1865
1866 #[test]
1867 fn test_replace_in_file_line_mismatch() {
1868 let temp_dir = TempDir::new().unwrap();
1869 let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nactual text\nline 3\n");
1870
1871 let mut results = vec![create_search_result_with_replacement(
1873 file_path.to_str().unwrap(),
1874 2,
1875 "expected text",
1876 LineEnding::Lf,
1877 "new text",
1878 true,
1879 None,
1880 )];
1881
1882 let result = replace_in_file(&mut results);
1884 assert!(result.is_ok());
1885
1886 assert_eq!(
1888 results[0].replace_result,
1889 Some(ReplaceResult::Error(
1890 "File changed since last search".to_owned()
1891 ))
1892 );
1893
1894 let new_content = std::fs::read_to_string(&file_path).unwrap();
1896 assert_eq!(new_content, "line 1\nactual text\nline 3\n");
1897 }
1898
1899 #[test]
1900 fn test_replace_in_file_nonexistent_file() {
1901 let mut results = vec![create_search_result_with_replacement(
1902 "/nonexistent/path/file.txt",
1903 1,
1904 "old",
1905 LineEnding::Lf,
1906 "new",
1907 true,
1908 None,
1909 )];
1910
1911 let result = replace_in_file(&mut results);
1912 assert!(result.is_err());
1913 }
1914
1915 #[test]
1916 fn test_replace_directory_errors() {
1917 let mut results = vec![create_search_result_with_replacement(
1918 "/",
1919 0,
1920 "foo",
1921 LineEnding::Lf,
1922 "bar",
1923 true,
1924 None,
1925 )];
1926
1927 let result = replace_in_file(&mut results);
1928 assert!(result.is_err());
1929 }
1930
1931 #[test]
1933 fn test_replace_in_memory() {
1934 let temp_dir = TempDir::new().unwrap();
1935
1936 let file_path = create_test_file(
1938 &temp_dir,
1939 "test.txt",
1940 "This is a test.\nIt contains search_term that should be replaced.\nMultiple lines with search_term here.",
1941 );
1942
1943 let result = replace_in_memory(&file_path, &fixed_search("search_term"), "replacement");
1944 assert!(result.is_ok());
1945 assert!(result.unwrap()); assert_file_content(
1948 &file_path,
1949 "This is a test.\nIt contains replacement that should be replaced.\nMultiple lines with replacement here.",
1950 );
1951
1952 let regex_path = create_test_file(
1954 &temp_dir,
1955 "regex_test.txt",
1956 "Number: 123, Code: 456, ID: 789",
1957 );
1958
1959 let result = replace_in_memory(®ex_path, ®ex_search(r"\d{3}"), "XXX");
1960 assert!(result.is_ok());
1961 assert!(result.unwrap());
1962
1963 assert_file_content(®ex_path, "Number: XXX, Code: XXX, ID: XXX");
1964 }
1965
1966 #[test]
1967 fn test_replace_in_memory_no_match() {
1968 let temp_dir = TempDir::new().unwrap();
1969 let file_path = create_test_file(
1970 &temp_dir,
1971 "no_match.txt",
1972 "This is a test file with no matches.",
1973 );
1974
1975 let result = replace_in_memory(&file_path, &fixed_search("nonexistent"), "replacement");
1976 assert!(result.is_ok());
1977 assert!(!result.unwrap()); assert_file_content(&file_path, "This is a test file with no matches.");
1981 }
1982
1983 #[test]
1984 fn test_replace_in_memory_empty_file() {
1985 let temp_dir = TempDir::new().unwrap();
1986 let file_path = create_test_file(&temp_dir, "empty.txt", "");
1987
1988 let result = replace_in_memory(&file_path, &fixed_search("anything"), "replacement");
1989 assert!(result.is_ok());
1990 assert!(!result.unwrap());
1991
1992 assert_file_content(&file_path, "");
1994 }
1995
1996 #[test]
1997 fn test_replace_in_memory_nonexistent_file() {
1998 let result = replace_in_memory(
1999 Path::new("/nonexistent/path/file.txt"),
2000 &fixed_search("test"),
2001 "replacement",
2002 );
2003 assert!(result.is_err());
2004 }
2005
2006 #[test]
2008 fn test_replace_chunked() {
2009 let temp_dir = TempDir::new().unwrap();
2010
2011 let file_path = create_test_file(
2013 &temp_dir,
2014 "test.txt",
2015 "This is line one.\nThis contains search_pattern to replace.\nAnother line with search_pattern here.\nFinal line.",
2016 );
2017
2018 let result =
2019 replace_line_by_line(&file_path, &fixed_search("search_pattern"), "replacement");
2020 assert!(result.is_ok());
2021 assert!(result.unwrap()); assert_file_content(
2024 &file_path,
2025 "This is line one.\nThis contains replacement to replace.\nAnother line with replacement here.\nFinal line.",
2026 );
2027
2028 let regex_path = create_test_file(
2030 &temp_dir,
2031 "regex.txt",
2032 "Line with numbers: 123 and 456.\nAnother line with 789.",
2033 );
2034
2035 let result = replace_line_by_line(®ex_path, ®ex_search(r"\d{3}"), "XXX");
2036 assert!(result.is_ok());
2037 assert!(result.unwrap());
2038
2039 assert_file_content(
2040 ®ex_path,
2041 "Line with numbers: XXX and XXX.\nAnother line with XXX.",
2042 );
2043 }
2044
2045 #[test]
2046 fn test_replace_chunked_no_match() {
2047 let temp_dir = TempDir::new().unwrap();
2048 let file_path = create_test_file(
2049 &temp_dir,
2050 "test.txt",
2051 "This is a test file with no matching patterns.",
2052 );
2053
2054 let result = replace_line_by_line(&file_path, &fixed_search("nonexistent"), "replacement");
2055 assert!(result.is_ok());
2056 assert!(!result.unwrap());
2057
2058 assert_file_content(&file_path, "This is a test file with no matching patterns.");
2060 }
2061
2062 #[test]
2063 fn test_replace_chunked_empty_file() {
2064 let temp_dir = TempDir::new().unwrap();
2065 let file_path = create_test_file(&temp_dir, "empty.txt", "");
2066
2067 let result = replace_line_by_line(&file_path, &fixed_search("anything"), "replacement");
2068 assert!(result.is_ok());
2069 assert!(!result.unwrap());
2070
2071 assert_file_content(&file_path, "");
2073 }
2074
2075 #[test]
2076 fn test_replace_chunked_nonexistent_file() {
2077 let result = replace_line_by_line(
2078 Path::new("/nonexistent/path/file.txt"),
2079 &fixed_search("test"),
2080 "replacement",
2081 );
2082 assert!(result.is_err());
2083 }
2084
2085 #[test]
2087 fn test_replace_all_in_file() {
2088 let temp_dir = TempDir::new().unwrap();
2089 let file_path = create_test_file(
2090 &temp_dir,
2091 "test.txt",
2092 "This is a test file.\nIt has some content to replace.\nThe word replace should be replaced.",
2093 );
2094
2095 let result = replace_all_in_file(&file_path, &fixed_search("replace"), "modify", false);
2096 assert!(result.is_ok());
2097 assert!(result.unwrap());
2098
2099 assert_file_content(
2100 &file_path,
2101 "This is a test file.\nIt has some content to modify.\nThe word modify should be modifyd.",
2102 );
2103 }
2104
2105 #[test]
2106 fn test_unicode_in_file() {
2107 let mut temp_file = NamedTempFile::new().unwrap();
2108 writeln!(temp_file, "Line with Greek: αβγδε").unwrap();
2109 write!(temp_file, "Line with Emoji: 😀 🚀 🌍\r\n").unwrap();
2110 write!(temp_file, "Line with Arabic: مرحبا بالعالم").unwrap();
2111 temp_file.flush().unwrap();
2112
2113 let search = SearchType::Pattern(Regex::new(r"\p{Greek}+").unwrap());
2114 let replacement = "GREEK";
2115 let results = search_file(temp_file.path(), &search, false)
2116 .unwrap()
2117 .into_iter()
2118 .filter_map(|r| add_replacement(r, &search, replacement))
2119 .collect::<Vec<_>>();
2120
2121 assert_eq!(results.len(), 1);
2122 assert_eq!(results[0].replacement, "Line with Greek: GREEK");
2123
2124 let search = SearchType::Pattern(Regex::new(r"🚀").unwrap());
2125 let replacement = "ROCKET";
2126 let results = search_file(temp_file.path(), &search, false)
2127 .unwrap()
2128 .into_iter()
2129 .filter_map(|r| add_replacement(r, &search, replacement))
2130 .collect::<Vec<_>>();
2131
2132 assert_eq!(results.len(), 1);
2133 assert_eq!(results[0].replacement, "Line with Emoji: 😀 ROCKET 🌍");
2134 let (_, line_ending) = line_content(&results[0].search_result);
2135 assert_eq!(line_ending, LineEnding::CrLf);
2136 }
2137
2138 mod search_file_tests {
2139 use super::*;
2140 use fancy_regex::Regex as FancyRegex;
2141 use regex::Regex;
2142 use std::io::Write;
2143 use tempfile::NamedTempFile;
2144
2145 #[test]
2146 fn test_search_file_simple_match() {
2147 let mut temp_file = NamedTempFile::new().unwrap();
2148 writeln!(temp_file, "line 1").unwrap();
2149 writeln!(temp_file, "search target").unwrap();
2150 writeln!(temp_file, "line 3").unwrap();
2151 temp_file.flush().unwrap();
2152
2153 let search = test_helpers::create_fixed_search("search");
2154 let replacement = "replace";
2155 let results = search_file(temp_file.path(), &search, false)
2156 .unwrap()
2157 .into_iter()
2158 .filter_map(|r| add_replacement(r, &search, replacement))
2159 .collect::<Vec<_>>();
2160
2161 assert_eq!(results.len(), 1);
2162 assert_eq!(results[0].search_result.start_line_number(), 2);
2163 let (content, _) = line_content(&results[0].search_result);
2164 assert_eq!(content, "search target");
2165 assert_eq!(results[0].replacement, "replace target");
2166 assert!(results[0].search_result.included);
2167 }
2168
2169 #[test]
2170 fn test_search_file_multiple_matches() {
2171 let mut temp_file = NamedTempFile::new().unwrap();
2172 writeln!(temp_file, "test line 1").unwrap();
2173 writeln!(temp_file, "test line 2").unwrap();
2174 writeln!(temp_file, "no match here").unwrap();
2175 writeln!(temp_file, "test line 4").unwrap();
2176 temp_file.flush().unwrap();
2177
2178 let search = test_helpers::create_fixed_search("test");
2179 let replacement = "replaced";
2180 let results = search_file(temp_file.path(), &search, false)
2181 .unwrap()
2182 .into_iter()
2183 .filter_map(|r| add_replacement(r, &search, replacement))
2184 .collect::<Vec<_>>();
2185
2186 assert_eq!(results.len(), 3);
2187 assert_eq!(results[0].search_result.start_line_number(), 1);
2188 assert_eq!(results[0].replacement, "replaced line 1");
2189 assert_eq!(results[1].search_result.start_line_number(), 2);
2190 assert_eq!(results[1].replacement, "replaced line 2");
2191 assert_eq!(results[2].search_result.start_line_number(), 4);
2192 assert_eq!(results[2].replacement, "replaced line 4");
2193 }
2194
2195 #[test]
2196 fn test_search_file_no_matches() {
2197 let mut temp_file = NamedTempFile::new().unwrap();
2198 writeln!(temp_file, "line 1").unwrap();
2199 writeln!(temp_file, "line 2").unwrap();
2200 writeln!(temp_file, "line 3").unwrap();
2201 temp_file.flush().unwrap();
2202
2203 let search = SearchType::Fixed("nonexistent".to_string());
2204 let replacement = "replace";
2205 let results = search_file(temp_file.path(), &search, false)
2206 .unwrap()
2207 .into_iter()
2208 .filter_map(|r| add_replacement(r, &search, replacement))
2209 .collect::<Vec<_>>();
2210
2211 assert_eq!(results.len(), 0);
2212 }
2213
2214 #[test]
2215 fn test_search_file_regex_pattern() {
2216 let mut temp_file = NamedTempFile::new().unwrap();
2217 writeln!(temp_file, "number: 123").unwrap();
2218 writeln!(temp_file, "text without numbers").unwrap();
2219 writeln!(temp_file, "another number: 456").unwrap();
2220 temp_file.flush().unwrap();
2221
2222 let search = SearchType::Pattern(Regex::new(r"\d+").unwrap());
2223 let replacement = "XXX";
2224 let results = search_file(temp_file.path(), &search, false)
2225 .unwrap()
2226 .into_iter()
2227 .filter_map(|r| add_replacement(r, &search, replacement))
2228 .collect::<Vec<_>>();
2229
2230 assert_eq!(results.len(), 2);
2231 assert_eq!(results[0].replacement, "number: XXX");
2232 assert_eq!(results[1].replacement, "another number: XXX");
2233 }
2234
2235 #[test]
2236 fn test_search_file_advanced_regex_pattern() {
2237 let mut temp_file = NamedTempFile::new().unwrap();
2238 writeln!(temp_file, "123abc456").unwrap();
2239 writeln!(temp_file, "abc").unwrap();
2240 writeln!(temp_file, "789xyz123").unwrap();
2241 writeln!(temp_file, "no match").unwrap();
2242 temp_file.flush().unwrap();
2243
2244 let search =
2246 SearchType::PatternAdvanced(FancyRegex::new(r"(?<=\d{3})abc(?=\d{3})").unwrap());
2247 let replacement = "REPLACED";
2248 let results = search_file(temp_file.path(), &search, false)
2249 .unwrap()
2250 .into_iter()
2251 .filter_map(|r| add_replacement(r, &search, replacement))
2252 .collect::<Vec<_>>();
2253
2254 assert_eq!(results.len(), 1);
2255 assert_eq!(results[0].replacement, "123REPLACED456");
2256 assert_eq!(results[0].search_result.start_line_number(), 1);
2257 }
2258
2259 #[test]
2260 fn test_search_file_empty_search() {
2261 let mut temp_file = NamedTempFile::new().unwrap();
2262 writeln!(temp_file, "some content").unwrap();
2263 temp_file.flush().unwrap();
2264
2265 let search = SearchType::Fixed("".to_string());
2266 let replacement = "replace";
2267 let results = search_file(temp_file.path(), &search, false)
2268 .unwrap()
2269 .into_iter()
2270 .filter_map(|r| add_replacement(r, &search, replacement))
2271 .collect::<Vec<_>>();
2272
2273 assert_eq!(results.len(), 0);
2274 }
2275
2276 #[test]
2277 fn test_search_file_preserves_line_endings() {
2278 let mut temp_file = NamedTempFile::new().unwrap();
2279 write!(temp_file, "line1\nline2\r\nline3").unwrap();
2280 temp_file.flush().unwrap();
2281
2282 let search = SearchType::Fixed("line".to_string());
2283 let replacement = "X";
2284 let results = search_file(temp_file.path(), &search, false)
2285 .unwrap()
2286 .into_iter()
2287 .filter_map(|r| add_replacement(r, &search, replacement))
2288 .collect::<Vec<_>>();
2289
2290 assert_eq!(results.len(), 3);
2291 let (_, le0) = line_content(&results[0].search_result);
2292 assert_eq!(le0, LineEnding::Lf);
2293 let (_, le1) = line_content(&results[1].search_result);
2294 assert_eq!(le1, LineEnding::CrLf);
2295 let (_, le2) = line_content(&results[2].search_result);
2296 assert_eq!(le2, LineEnding::None);
2297 }
2298
2299 #[test]
2300 fn test_search_file_nonexistent() {
2301 let nonexistent_path = PathBuf::from("/this/file/does/not/exist.txt");
2302 let search = test_helpers::create_fixed_search("test");
2303 let results = search_file(&nonexistent_path, &search, false);
2304 assert!(results.is_err());
2305 }
2306
2307 #[test]
2308 fn test_search_file_unicode_content() {
2309 let mut temp_file = NamedTempFile::new().unwrap();
2310 writeln!(temp_file, "Hello 世界!").unwrap();
2311 writeln!(temp_file, "Здравствуй мир!").unwrap();
2312 writeln!(temp_file, "🚀 Rocket").unwrap();
2313 temp_file.flush().unwrap();
2314
2315 let search = SearchType::Fixed("世界".to_string());
2316 let replacement = "World";
2317 let results = search_file(temp_file.path(), &search, false)
2318 .unwrap()
2319 .into_iter()
2320 .filter_map(|r| add_replacement(r, &search, replacement))
2321 .collect::<Vec<_>>();
2322
2323 assert_eq!(results.len(), 1);
2324 assert_eq!(results[0].replacement, "Hello World!");
2325 }
2326
2327 #[test]
2328 fn test_search_file_with_binary_content() {
2329 let mut temp_file = NamedTempFile::new().unwrap();
2330 let binary_data = [0x00, 0x01, 0x02, 0xFF, 0xFE];
2332 temp_file.write_all(&binary_data).unwrap();
2333 temp_file.flush().unwrap();
2334
2335 let search = test_helpers::create_fixed_search("test");
2336 let replacement = "replace";
2337 let results = search_file(temp_file.path(), &search, false)
2338 .unwrap()
2339 .into_iter()
2340 .filter_map(|r| add_replacement(r, &search, replacement))
2341 .collect::<Vec<_>>();
2342
2343 assert_eq!(results.len(), 0);
2344 }
2345
2346 #[test]
2347 fn test_search_file_large_content() {
2348 let mut temp_file = NamedTempFile::new().unwrap();
2349
2350 for i in 0..1000 {
2352 if i % 100 == 0 {
2353 writeln!(temp_file, "target line {i}").unwrap();
2354 } else {
2355 writeln!(temp_file, "normal line {i}").unwrap();
2356 }
2357 }
2358 temp_file.flush().unwrap();
2359
2360 let search = SearchType::Fixed("target".to_string());
2361 let replacement = "found";
2362 let results = search_file(temp_file.path(), &search, false)
2363 .unwrap()
2364 .into_iter()
2365 .filter_map(|r| add_replacement(r, &search, replacement))
2366 .collect::<Vec<_>>();
2367
2368 assert_eq!(results.len(), 10); assert_eq!(results[0].search_result.start_line_number(), 1); assert_eq!(results[1].search_result.start_line_number(), 101);
2371 assert_eq!(results[9].search_result.start_line_number(), 901);
2372 }
2373 }
2374
2375 mod replace_if_match_tests {
2376 use crate::validation::SearchConfig;
2377
2378 use super::*;
2379
2380 mod test_helpers {
2381 use crate::{
2382 search::ParsedSearchConfig,
2383 validation::{
2384 SearchConfig, SimpleErrorHandler, ValidationResult,
2385 validate_search_configuration,
2386 },
2387 };
2388
2389 pub fn must_parse_search_config(search_config: SearchConfig<'_>) -> ParsedSearchConfig {
2390 let mut error_handler = SimpleErrorHandler::new();
2391 let (search_config, _dir_config) =
2392 match validate_search_configuration(search_config, None, &mut error_handler)
2393 .unwrap()
2394 {
2395 ValidationResult::Success(search_config) => search_config,
2396 ValidationResult::ValidationErrors => {
2397 panic!("{}", error_handler.errors_str().unwrap());
2398 }
2399 };
2400 search_config
2401 }
2402 }
2403
2404 mod fixed_string_tests {
2405 use super::*;
2406
2407 mod whole_word_true_match_case_true {
2408
2409 use super::*;
2410
2411 #[test]
2412 fn test_basic_replacement() {
2413 let search_config = SearchConfig {
2414 search_text: "world",
2415 fixed_strings: true,
2416 match_whole_word: true,
2417 match_case: true,
2418 replacement_text: "earth",
2419 advanced_regex: false,
2420 multiline: false,
2421 interpret_escape_sequences: false,
2422 };
2423 let parsed = test_helpers::must_parse_search_config(search_config);
2424
2425 assert_eq!(
2426 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2427 Some("hello earth".to_string())
2428 );
2429 }
2430
2431 #[test]
2432 fn test_case_sensitivity() {
2433 let search_config = SearchConfig {
2434 search_text: "world",
2435 fixed_strings: true,
2436 match_whole_word: true,
2437 match_case: true,
2438 replacement_text: "earth",
2439 advanced_regex: false,
2440 multiline: false,
2441 interpret_escape_sequences: false,
2442 };
2443 let parsed = test_helpers::must_parse_search_config(search_config);
2444
2445 assert_eq!(
2446 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2447 None
2448 );
2449 }
2450
2451 #[test]
2452 fn test_word_boundaries() {
2453 let search_config = SearchConfig {
2454 search_text: "world",
2455 fixed_strings: true,
2456 match_whole_word: true,
2457 match_case: true,
2458 replacement_text: "earth",
2459 advanced_regex: false,
2460 multiline: false,
2461 interpret_escape_sequences: false,
2462 };
2463 let parsed = test_helpers::must_parse_search_config(search_config);
2464
2465 assert_eq!(
2466 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2467 None
2468 );
2469 }
2470 }
2471
2472 mod whole_word_true_match_case_false {
2473 use super::*;
2474
2475 #[test]
2476 fn test_basic_replacement() {
2477 let search_config = SearchConfig {
2478 search_text: "world",
2479 fixed_strings: true,
2480 match_whole_word: true,
2481 match_case: false,
2482 replacement_text: "earth",
2483 advanced_regex: false,
2484 multiline: false,
2485 interpret_escape_sequences: false,
2486 };
2487 let parsed = test_helpers::must_parse_search_config(search_config);
2488
2489 assert_eq!(
2490 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2491 Some("hello earth".to_string())
2492 );
2493 }
2494
2495 #[test]
2496 fn test_case_insensitivity() {
2497 let search_config = SearchConfig {
2498 search_text: "world",
2499 fixed_strings: true,
2500 match_whole_word: true,
2501 match_case: false,
2502 replacement_text: "earth",
2503 advanced_regex: false,
2504 multiline: false,
2505 interpret_escape_sequences: false,
2506 };
2507 let parsed = test_helpers::must_parse_search_config(search_config);
2508
2509 assert_eq!(
2510 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2511 Some("hello earth".to_string())
2512 );
2513 }
2514
2515 #[test]
2516 fn test_word_boundaries() {
2517 let search_config = SearchConfig {
2518 search_text: "world",
2519 fixed_strings: true,
2520 match_whole_word: true,
2521 match_case: false,
2522 replacement_text: "earth",
2523 advanced_regex: false,
2524 multiline: false,
2525 interpret_escape_sequences: false,
2526 };
2527 let parsed = test_helpers::must_parse_search_config(search_config);
2528
2529 assert_eq!(
2530 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2531 None
2532 );
2533 }
2534
2535 #[test]
2536 fn test_unicode() {
2537 let search_config = SearchConfig {
2538 search_text: "café",
2539 fixed_strings: true,
2540 match_whole_word: true,
2541 match_case: false,
2542 replacement_text: "restaurant",
2543 advanced_regex: false,
2544 multiline: false,
2545 interpret_escape_sequences: false,
2546 };
2547 let parsed = test_helpers::must_parse_search_config(search_config);
2548
2549 assert_eq!(
2550 replace_all_if_match("Hello CAFÉ table", &parsed.search, &parsed.replace),
2551 Some("Hello restaurant table".to_string())
2552 );
2553 }
2554 }
2555
2556 mod whole_word_false_match_case_true {
2557 use super::*;
2558
2559 #[test]
2560 fn test_basic_replacement() {
2561 let search_config = SearchConfig {
2562 search_text: "world",
2563 fixed_strings: true,
2564 match_whole_word: false,
2565 match_case: true,
2566 replacement_text: "earth",
2567 advanced_regex: false,
2568 multiline: false,
2569 interpret_escape_sequences: false,
2570 };
2571 let parsed = test_helpers::must_parse_search_config(search_config);
2572
2573 assert_eq!(
2574 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2575 Some("hello earth".to_string())
2576 );
2577 }
2578
2579 #[test]
2580 fn test_case_sensitivity() {
2581 let search_config = SearchConfig {
2582 search_text: "world",
2583 fixed_strings: true,
2584 match_whole_word: false,
2585 match_case: true,
2586 replacement_text: "earth",
2587 advanced_regex: false,
2588 multiline: false,
2589 interpret_escape_sequences: false,
2590 };
2591 let parsed = test_helpers::must_parse_search_config(search_config);
2592
2593 assert_eq!(
2594 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2595 None
2596 );
2597 }
2598
2599 #[test]
2600 fn test_substring_matches() {
2601 let search_config = SearchConfig {
2602 search_text: "world",
2603 fixed_strings: true,
2604 match_whole_word: false,
2605 match_case: true,
2606 replacement_text: "earth",
2607 advanced_regex: false,
2608 multiline: false,
2609 interpret_escape_sequences: false,
2610 };
2611 let parsed = test_helpers::must_parse_search_config(search_config);
2612
2613 assert_eq!(
2614 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2615 Some("earthwide".to_string())
2616 );
2617 }
2618 }
2619
2620 mod whole_word_false_match_case_false {
2621 use super::*;
2622
2623 #[test]
2624 fn test_basic_replacement() {
2625 let search_config = SearchConfig {
2626 search_text: "world",
2627 fixed_strings: true,
2628 match_whole_word: false,
2629 match_case: false,
2630 replacement_text: "earth",
2631 advanced_regex: false,
2632 multiline: false,
2633 interpret_escape_sequences: false,
2634 };
2635 let parsed = test_helpers::must_parse_search_config(search_config);
2636
2637 assert_eq!(
2638 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2639 Some("hello earth".to_string())
2640 );
2641 }
2642
2643 #[test]
2644 fn test_case_insensitivity() {
2645 let search_config = SearchConfig {
2646 search_text: "world",
2647 fixed_strings: true,
2648 match_whole_word: false,
2649 match_case: false,
2650 replacement_text: "earth",
2651 advanced_regex: false,
2652 multiline: false,
2653 interpret_escape_sequences: false,
2654 };
2655 let parsed = test_helpers::must_parse_search_config(search_config);
2656
2657 assert_eq!(
2658 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2659 Some("hello earth".to_string())
2660 );
2661 }
2662
2663 #[test]
2664 fn test_substring_matches() {
2665 let search_config = SearchConfig {
2666 search_text: "world",
2667 fixed_strings: true,
2668 match_whole_word: false,
2669 match_case: false,
2670 replacement_text: "earth",
2671 advanced_regex: false,
2672 multiline: false,
2673 interpret_escape_sequences: false,
2674 };
2675 let parsed = test_helpers::must_parse_search_config(search_config);
2676
2677 assert_eq!(
2678 replace_all_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
2679 Some("earthWIDE".to_string())
2680 );
2681 }
2682 }
2683 }
2684
2685 mod regex_pattern_tests {
2686 use super::*;
2687
2688 mod whole_word_true_match_case_true {
2689 use crate::validation::SearchConfig;
2690
2691 use super::*;
2692
2693 #[test]
2694 fn test_basic_regex() {
2695 let re_str = r"w\w+d";
2696 let search_config = SearchConfig {
2697 search_text: re_str,
2698 fixed_strings: false,
2699 match_whole_word: true,
2700 match_case: true,
2701 replacement_text: "earth",
2702 advanced_regex: false,
2703 multiline: false,
2704 interpret_escape_sequences: false,
2705 };
2706 let parsed = test_helpers::must_parse_search_config(search_config);
2707
2708 assert_eq!(
2709 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2710 Some("hello earth".to_string())
2711 );
2712 }
2713
2714 #[test]
2715 fn test_case_sensitivity() {
2716 let re_str = r"world";
2717 let search_config = SearchConfig {
2718 search_text: re_str,
2719 fixed_strings: false,
2720 match_whole_word: true,
2721 match_case: true,
2722 replacement_text: "earth",
2723 advanced_regex: false,
2724 multiline: false,
2725 interpret_escape_sequences: false,
2726 };
2727 let parsed = test_helpers::must_parse_search_config(search_config);
2728
2729 assert_eq!(
2730 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2731 None
2732 );
2733 }
2734
2735 #[test]
2736 fn test_word_boundaries() {
2737 let re_str = r"world";
2738 let search_config = SearchConfig {
2739 search_text: re_str,
2740 fixed_strings: false,
2741 match_whole_word: true,
2742 match_case: true,
2743 replacement_text: "earth",
2744 advanced_regex: false,
2745 multiline: false,
2746 interpret_escape_sequences: false,
2747 };
2748 let parsed = test_helpers::must_parse_search_config(search_config);
2749
2750 assert_eq!(
2751 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2752 None
2753 );
2754 }
2755 }
2756
2757 mod whole_word_true_match_case_false {
2758 use super::*;
2759
2760 #[test]
2761 fn test_basic_regex() {
2762 let re_str = r"w\w+d";
2763 let search_config = SearchConfig {
2764 search_text: re_str,
2765 fixed_strings: false,
2766 match_whole_word: true,
2767 match_case: false,
2768 replacement_text: "earth",
2769 advanced_regex: false,
2770 multiline: false,
2771 interpret_escape_sequences: false,
2772 };
2773 let parsed = test_helpers::must_parse_search_config(search_config);
2774
2775 assert_eq!(
2776 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2777 Some("hello earth".to_string())
2778 );
2779 }
2780
2781 #[test]
2782 fn test_word_boundaries() {
2783 let re_str = r"world";
2784 let search_config = SearchConfig {
2785 search_text: re_str,
2786 fixed_strings: false,
2787 match_whole_word: true,
2788 match_case: false,
2789 replacement_text: "earth",
2790 advanced_regex: false,
2791 multiline: false,
2792 interpret_escape_sequences: false,
2793 };
2794 let parsed = test_helpers::must_parse_search_config(search_config);
2795
2796 assert_eq!(
2797 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2798 None
2799 );
2800 }
2801
2802 #[test]
2803 fn test_special_characters() {
2804 let re_str = r"\d+";
2805 let search_config = SearchConfig {
2806 search_text: re_str,
2807 fixed_strings: false,
2808 match_whole_word: true,
2809 match_case: false,
2810 replacement_text: "NUM",
2811 advanced_regex: false,
2812 multiline: false,
2813 interpret_escape_sequences: false,
2814 };
2815 let parsed = test_helpers::must_parse_search_config(search_config);
2816
2817 assert_eq!(
2818 replace_all_if_match("test 123 number", &parsed.search, &parsed.replace),
2819 Some("test NUM number".to_string())
2820 );
2821 }
2822
2823 #[test]
2824 fn test_unicode_word_boundaries() {
2825 let re_str = r"\b\p{Script=Han}{2}\b";
2826 let search_config = SearchConfig {
2827 search_text: re_str,
2828 fixed_strings: false,
2829 match_whole_word: true,
2830 match_case: false,
2831 replacement_text: "XX",
2832 advanced_regex: false,
2833 multiline: false,
2834 interpret_escape_sequences: false,
2835 };
2836 let parsed = test_helpers::must_parse_search_config(search_config);
2837
2838 assert!(
2839 replace_all_if_match("Text 世界 more", &parsed.search, &parsed.replace)
2840 .is_some()
2841 );
2842 assert!(replace_all_if_match("Text世界more", &parsed.search, "XX").is_none());
2843 }
2844 }
2845
2846 mod whole_word_false_match_case_true {
2847 use super::*;
2848
2849 #[test]
2850 fn test_basic_regex() {
2851 let re_str = r"w\w+d";
2852 let search_config = SearchConfig {
2853 search_text: re_str,
2854 fixed_strings: false,
2855 match_whole_word: false,
2856 match_case: true,
2857 replacement_text: "earth",
2858 advanced_regex: false,
2859 multiline: false,
2860 interpret_escape_sequences: false,
2861 };
2862 let parsed = test_helpers::must_parse_search_config(search_config);
2863
2864 assert_eq!(
2865 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
2866 Some("hello earth".to_string())
2867 );
2868 }
2869
2870 #[test]
2871 fn test_case_sensitivity() {
2872 let re_str = r"world";
2873 let search_config = SearchConfig {
2874 search_text: re_str,
2875 fixed_strings: false,
2876 match_whole_word: false,
2877 match_case: true,
2878 replacement_text: "earth",
2879 advanced_regex: false,
2880 multiline: false,
2881 interpret_escape_sequences: false,
2882 };
2883 let parsed = test_helpers::must_parse_search_config(search_config);
2884
2885 assert_eq!(
2886 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2887 None
2888 );
2889 }
2890
2891 #[test]
2892 fn test_substring_matches() {
2893 let re_str = r"world";
2894 let search_config = SearchConfig {
2895 search_text: re_str,
2896 fixed_strings: false,
2897 match_whole_word: false,
2898 match_case: true,
2899 replacement_text: "earth",
2900 advanced_regex: false,
2901 multiline: false,
2902 interpret_escape_sequences: false,
2903 };
2904 let parsed = test_helpers::must_parse_search_config(search_config);
2905
2906 assert_eq!(
2907 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
2908 Some("earthwide".to_string())
2909 );
2910 }
2911 }
2912
2913 mod whole_word_false_match_case_false {
2914 use super::*;
2915
2916 #[test]
2917 fn test_basic_regex() {
2918 let re_str = r"w\w+d";
2919 let search_config = SearchConfig {
2920 search_text: re_str,
2921 fixed_strings: false,
2922 match_whole_word: false,
2923 match_case: false,
2924 replacement_text: "earth",
2925 advanced_regex: false,
2926 multiline: false,
2927 interpret_escape_sequences: false,
2928 };
2929 let parsed = test_helpers::must_parse_search_config(search_config);
2930
2931 assert_eq!(
2932 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
2933 Some("hello earth".to_string())
2934 );
2935 }
2936
2937 #[test]
2938 fn test_substring_matches() {
2939 let re_str = r"world";
2940 let search_config = SearchConfig {
2941 search_text: re_str,
2942 fixed_strings: false,
2943 match_whole_word: false,
2944 match_case: false,
2945 replacement_text: "earth",
2946 advanced_regex: false,
2947 multiline: false,
2948 interpret_escape_sequences: false,
2949 };
2950 let parsed = test_helpers::must_parse_search_config(search_config);
2951
2952 assert_eq!(
2953 replace_all_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
2954 Some("earthWIDE".to_string())
2955 );
2956 }
2957
2958 #[test]
2959 fn test_complex_pattern() {
2960 let re_str = r"\d{3}-\d{2}-\d{4}";
2961 let search_config = SearchConfig {
2962 search_text: re_str,
2963 fixed_strings: false,
2964 match_whole_word: false,
2965 match_case: false,
2966 replacement_text: "XXX-XX-XXXX",
2967 advanced_regex: false,
2968 multiline: false,
2969 interpret_escape_sequences: false,
2970 };
2971 let parsed = test_helpers::must_parse_search_config(search_config);
2972
2973 assert_eq!(
2974 replace_all_if_match("SSN: 123-45-6789", &parsed.search, &parsed.replace),
2975 Some("SSN: XXX-XX-XXXX".to_string())
2976 );
2977 }
2978 }
2979 }
2980
2981 mod fancy_regex_pattern_tests {
2982 use super::*;
2983
2984 mod whole_word_true_match_case_true {
2985
2986 use super::*;
2987
2988 #[test]
2989 fn test_lookbehind() {
2990 let re_str = r"(?<=@)\w+";
2991 let search_config = SearchConfig {
2992 search_text: re_str,
2993 match_whole_word: true,
2994 fixed_strings: false,
2995 advanced_regex: true,
2996 multiline: false,
2997 match_case: true,
2998 replacement_text: "domain",
2999 interpret_escape_sequences: false,
3000 };
3001 let parsed = test_helpers::must_parse_search_config(search_config);
3002
3003 assert_eq!(
3004 replace_all_if_match(
3005 "email: user@example.com",
3006 &parsed.search,
3007 &parsed.replace
3008 ),
3009 Some("email: user@domain.com".to_string())
3010 );
3011 }
3012
3013 #[test]
3014 fn test_lookahead() {
3015 let re_str = r"\w+(?=\.\w+$)";
3016 let search_config = SearchConfig {
3017 search_text: re_str,
3018 match_whole_word: true,
3019 fixed_strings: false,
3020 advanced_regex: true,
3021 multiline: false,
3022 match_case: true,
3023 replacement_text: "report",
3024 interpret_escape_sequences: false,
3025 };
3026 let parsed = test_helpers::must_parse_search_config(search_config);
3027
3028 assert_eq!(
3029 replace_all_if_match("file: document.pdf", &parsed.search, &parsed.replace),
3030 Some("file: report.pdf".to_string())
3031 );
3032 }
3033
3034 #[test]
3035 fn test_case_sensitivity() {
3036 let re_str = r"world";
3037 let search_config = SearchConfig {
3038 search_text: re_str,
3039 match_whole_word: true,
3040 fixed_strings: false,
3041 advanced_regex: true,
3042 multiline: false,
3043 match_case: true,
3044 replacement_text: "earth",
3045 interpret_escape_sequences: false,
3046 };
3047 let parsed = test_helpers::must_parse_search_config(search_config);
3048
3049 assert_eq!(
3050 replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
3051 None
3052 );
3053 }
3054 }
3055
3056 mod whole_word_true_match_case_false {
3057 use super::*;
3058
3059 #[test]
3060 fn test_lookbehind_case_insensitive() {
3061 let re_str = r"(?<=@)\w+";
3062 let search_config = SearchConfig {
3063 search_text: re_str,
3064 match_whole_word: true,
3065 fixed_strings: false,
3066 advanced_regex: true,
3067 multiline: false,
3068 match_case: false,
3069 replacement_text: "domain",
3070 interpret_escape_sequences: false,
3071 };
3072 let parsed = test_helpers::must_parse_search_config(search_config);
3073
3074 assert_eq!(
3075 replace_all_if_match(
3076 "email: user@EXAMPLE.com",
3077 &parsed.search,
3078 &parsed.replace
3079 ),
3080 Some("email: user@domain.com".to_string())
3081 );
3082 }
3083
3084 #[test]
3085 fn test_word_boundaries() {
3086 let re_str = r"world";
3087 let search_config = SearchConfig {
3088 search_text: re_str,
3089 match_whole_word: true,
3090 fixed_strings: false,
3091 advanced_regex: true,
3092 multiline: false,
3093 match_case: false,
3094 replacement_text: "earth",
3095 interpret_escape_sequences: false,
3096 };
3097 let parsed = test_helpers::must_parse_search_config(search_config);
3098
3099 assert_eq!(
3100 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
3101 None
3102 );
3103 }
3104 }
3105
3106 mod whole_word_false_match_case_true {
3107 use super::*;
3108
3109 #[test]
3110 fn test_complex_pattern() {
3111 let re_str = r"(?<=\d{4}-\d{2}-\d{2}T)\d{2}:\d{2}";
3112 let search_config = SearchConfig {
3113 search_text: re_str,
3114 match_whole_word: false,
3115 fixed_strings: false,
3116 advanced_regex: true,
3117 multiline: false,
3118 match_case: true,
3119 replacement_text: "XX:XX",
3120 interpret_escape_sequences: false,
3121 };
3122 let parsed = test_helpers::must_parse_search_config(search_config);
3123
3124 assert_eq!(
3125 replace_all_if_match(
3126 "Timestamp: 2023-01-15T14:30:00Z",
3127 &parsed.search,
3128 &parsed.replace
3129 ),
3130 Some("Timestamp: 2023-01-15TXX:XX:00Z".to_string())
3131 );
3132 }
3133
3134 #[test]
3135 fn test_case_sensitivity() {
3136 let re_str = r"WORLD";
3137 let search_config = SearchConfig {
3138 search_text: re_str,
3139 match_whole_word: false,
3140 fixed_strings: false,
3141 advanced_regex: true,
3142 multiline: false,
3143 match_case: true,
3144 replacement_text: "earth",
3145 interpret_escape_sequences: false,
3146 };
3147 let parsed = test_helpers::must_parse_search_config(search_config);
3148
3149 assert_eq!(
3150 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
3151 None
3152 );
3153 }
3154 }
3155
3156 mod whole_word_false_match_case_false {
3157 use super::*;
3158
3159 #[test]
3160 fn test_complex_pattern_case_insensitive() {
3161 let re_str = r"(?<=\[)\w+(?=\])";
3162 let search_config = SearchConfig {
3163 search_text: re_str,
3164 match_whole_word: false,
3165 fixed_strings: false,
3166 advanced_regex: true,
3167 multiline: false,
3168 match_case: false,
3169 replacement_text: "ERROR",
3170 interpret_escape_sequences: false,
3171 };
3172 let parsed = test_helpers::must_parse_search_config(search_config);
3173
3174 assert_eq!(
3175 replace_all_if_match(
3176 "Tag: [WARNING] message",
3177 &parsed.search,
3178 &parsed.replace
3179 ),
3180 Some("Tag: [ERROR] message".to_string())
3181 );
3182 }
3183
3184 #[test]
3185 fn test_unicode_support() {
3186 let re_str = r"\p{Greek}+";
3187 let search_config = SearchConfig {
3188 search_text: re_str,
3189 match_whole_word: false,
3190 fixed_strings: false,
3191 advanced_regex: true,
3192 multiline: false,
3193 match_case: false,
3194 replacement_text: "GREEK",
3195 interpret_escape_sequences: false,
3196 };
3197 let parsed = test_helpers::must_parse_search_config(search_config);
3198
3199 assert_eq!(
3200 replace_all_if_match("Symbol: αβγδ", &parsed.search, &parsed.replace),
3201 Some("Symbol: GREEK".to_string())
3202 );
3203 }
3204 }
3205 }
3206
3207 #[test]
3208 fn test_multiple_replacements() {
3209 let search_config = SearchConfig {
3210 search_text: "world",
3211 fixed_strings: true,
3212 match_whole_word: true,
3213 match_case: false,
3214 replacement_text: "earth",
3215 advanced_regex: false,
3216 multiline: false,
3217 interpret_escape_sequences: false,
3218 };
3219 let parsed = test_helpers::must_parse_search_config(search_config);
3220 assert_eq!(
3221 replace_all_if_match("world hello world", &parsed.search, &parsed.replace),
3222 Some("earth hello earth".to_string())
3223 );
3224 }
3225
3226 #[test]
3227 fn test_no_match() {
3228 let search_config = SearchConfig {
3229 search_text: "world",
3230 fixed_strings: true,
3231 match_whole_word: true,
3232 match_case: false,
3233 replacement_text: "earth",
3234 advanced_regex: false,
3235 multiline: false,
3236 interpret_escape_sequences: false,
3237 };
3238 let parsed = test_helpers::must_parse_search_config(search_config);
3239 assert_eq!(
3240 replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
3241 None
3242 );
3243 let search_config = SearchConfig {
3244 search_text: "world",
3245 fixed_strings: true,
3246 match_whole_word: true,
3247 match_case: false,
3248 replacement_text: "earth",
3249 advanced_regex: false,
3250 multiline: false,
3251 interpret_escape_sequences: false,
3252 };
3253 let parsed = test_helpers::must_parse_search_config(search_config);
3254 assert_eq!(
3255 replace_all_if_match("_world_", &parsed.search, &parsed.replace),
3256 None
3257 );
3258 }
3259
3260 #[test]
3261 fn test_word_boundaries() {
3262 let search_config = SearchConfig {
3263 search_text: "world",
3264 fixed_strings: true,
3265 match_whole_word: true,
3266 match_case: false,
3267 replacement_text: "earth",
3268 advanced_regex: false,
3269 multiline: false,
3270 interpret_escape_sequences: false,
3271 };
3272 let parsed = test_helpers::must_parse_search_config(search_config);
3273 assert_eq!(
3274 replace_all_if_match(",world-", &parsed.search, &parsed.replace),
3275 Some(",earth-".to_string())
3276 );
3277 let search_config = SearchConfig {
3278 search_text: "world",
3279 fixed_strings: true,
3280 match_whole_word: true,
3281 match_case: false,
3282 replacement_text: "earth",
3283 advanced_regex: false,
3284 multiline: false,
3285 interpret_escape_sequences: false,
3286 };
3287 let parsed = test_helpers::must_parse_search_config(search_config);
3288 assert_eq!(
3289 replace_all_if_match("world-word", &parsed.search, &parsed.replace),
3290 Some("earth-word".to_string())
3291 );
3292 let search_config = SearchConfig {
3293 search_text: "world",
3294 fixed_strings: true,
3295 match_whole_word: true,
3296 match_case: false,
3297 replacement_text: "earth",
3298 advanced_regex: false,
3299 multiline: false,
3300 interpret_escape_sequences: false,
3301 };
3302 let parsed = test_helpers::must_parse_search_config(search_config);
3303 assert_eq!(
3304 replace_all_if_match("Hello-world!", &parsed.search, &parsed.replace),
3305 Some("Hello-earth!".to_string())
3306 );
3307 }
3308
3309 #[test]
3310 fn test_case_sensitive() {
3311 let search_config = SearchConfig {
3312 search_text: "world",
3313 fixed_strings: true,
3314 match_whole_word: true,
3315 match_case: true,
3316 replacement_text: "earth",
3317 advanced_regex: false,
3318 multiline: false,
3319 interpret_escape_sequences: false,
3320 };
3321 let parsed = test_helpers::must_parse_search_config(search_config);
3322 assert_eq!(
3323 replace_all_if_match("Hello WORLD", &parsed.search, &parsed.replace),
3324 None
3325 );
3326 let search_config = SearchConfig {
3327 search_text: "wOrld",
3328 fixed_strings: true,
3329 match_whole_word: true,
3330 match_case: true,
3331 replacement_text: "earth",
3332 advanced_regex: false,
3333 multiline: false,
3334 interpret_escape_sequences: false,
3335 };
3336 let parsed = test_helpers::must_parse_search_config(search_config);
3337 assert_eq!(
3338 replace_all_if_match("Hello world", &parsed.search, &parsed.replace),
3339 None
3340 );
3341 }
3342
3343 #[test]
3344 fn test_empty_strings() {
3345 let search_config = SearchConfig {
3346 search_text: "world",
3347 fixed_strings: true,
3348 match_whole_word: true,
3349 match_case: false,
3350 replacement_text: "earth",
3351 advanced_regex: false,
3352 multiline: false,
3353 interpret_escape_sequences: false,
3354 };
3355 let parsed = test_helpers::must_parse_search_config(search_config);
3356 assert_eq!(
3357 replace_all_if_match("", &parsed.search, &parsed.replace),
3358 None
3359 );
3360 let search_config = SearchConfig {
3361 search_text: "",
3362 fixed_strings: true,
3363 match_whole_word: true,
3364 match_case: false,
3365 replacement_text: "earth",
3366 advanced_regex: false,
3367 multiline: false,
3368 interpret_escape_sequences: false,
3369 };
3370 let parsed = test_helpers::must_parse_search_config(search_config);
3371 assert_eq!(
3372 replace_all_if_match("hello world", &parsed.search, &parsed.replace),
3373 None
3374 );
3375 }
3376
3377 #[test]
3378 fn test_substring_no_match() {
3379 let search_config = SearchConfig {
3380 search_text: "world",
3381 fixed_strings: true,
3382 match_whole_word: true,
3383 match_case: false,
3384 replacement_text: "earth",
3385 advanced_regex: false,
3386 multiline: false,
3387 interpret_escape_sequences: false,
3388 };
3389 let parsed = test_helpers::must_parse_search_config(search_config);
3390 assert_eq!(
3391 replace_all_if_match("worldwide web", &parsed.search, &parsed.replace),
3392 None
3393 );
3394 let search_config = SearchConfig {
3395 search_text: "world",
3396 fixed_strings: true,
3397 match_whole_word: true,
3398 match_case: false,
3399 replacement_text: "earth",
3400 advanced_regex: false,
3401 multiline: false,
3402 interpret_escape_sequences: false,
3403 };
3404 let parsed = test_helpers::must_parse_search_config(search_config);
3405 assert_eq!(
3406 replace_all_if_match("underworld", &parsed.search, &parsed.replace),
3407 None
3408 );
3409 }
3410
3411 #[test]
3412 fn test_special_regex_chars() {
3413 let search_config = SearchConfig {
3414 search_text: "(world)",
3415 fixed_strings: true,
3416 match_whole_word: true,
3417 match_case: false,
3418 replacement_text: "earth",
3419 advanced_regex: false,
3420 multiline: false,
3421 interpret_escape_sequences: false,
3422 };
3423 let parsed = test_helpers::must_parse_search_config(search_config);
3424 assert_eq!(
3425 replace_all_if_match("hello (world)", &parsed.search, &parsed.replace),
3426 Some("hello earth".to_string())
3427 );
3428 let search_config = SearchConfig {
3429 search_text: "world.*",
3430 fixed_strings: true,
3431 match_whole_word: true,
3432 match_case: false,
3433 replacement_text: "ea+rth",
3434 advanced_regex: false,
3435 multiline: false,
3436 interpret_escape_sequences: false,
3437 };
3438 let parsed = test_helpers::must_parse_search_config(search_config);
3439 assert_eq!(
3440 replace_all_if_match("hello world.*", &parsed.search, &parsed.replace),
3441 Some("hello ea+rth".to_string())
3442 );
3443 }
3444
3445 #[test]
3446 fn test_basic_regex_patterns() {
3447 let re_str = r"ax*b";
3448 let search_config = SearchConfig {
3449 search_text: re_str,
3450 fixed_strings: false,
3451 match_whole_word: true,
3452 match_case: false,
3453 replacement_text: "NEW",
3454 advanced_regex: false,
3455 multiline: false,
3456 interpret_escape_sequences: false,
3457 };
3458 let parsed = test_helpers::must_parse_search_config(search_config);
3459 assert_eq!(
3460 replace_all_if_match("foo axxxxb bar", &parsed.search, &parsed.replace),
3461 Some("foo NEW bar".to_string())
3462 );
3463 let search_config = SearchConfig {
3464 search_text: re_str,
3465 fixed_strings: false,
3466 match_whole_word: true,
3467 match_case: false,
3468 replacement_text: "NEW",
3469 advanced_regex: false,
3470 multiline: false,
3471 interpret_escape_sequences: false,
3472 };
3473 let parsed = test_helpers::must_parse_search_config(search_config);
3474 assert_eq!(
3475 replace_all_if_match("fooaxxxxb bar", &parsed.search, &parsed.replace),
3476 None
3477 );
3478 }
3479
3480 #[test]
3481 fn test_patterns_with_spaces() {
3482 let re_str = r"hel+o world";
3483 let search_config = SearchConfig {
3484 search_text: re_str,
3485 fixed_strings: false,
3486 match_whole_word: true,
3487 match_case: false,
3488 replacement_text: "hi earth",
3489 advanced_regex: false,
3490 multiline: false,
3491 interpret_escape_sequences: false,
3492 };
3493 let parsed = test_helpers::must_parse_search_config(search_config);
3494 assert_eq!(
3495 replace_all_if_match("say hello world!", &parsed.search, &parsed.replace),
3496 Some("say hi earth!".to_string())
3497 );
3498 let search_config = SearchConfig {
3499 search_text: re_str,
3500 fixed_strings: false,
3501 match_whole_word: true,
3502 match_case: false,
3503 replacement_text: "hi earth",
3504 advanced_regex: false,
3505 multiline: false,
3506 interpret_escape_sequences: false,
3507 };
3508 let parsed = test_helpers::must_parse_search_config(search_config);
3509 assert_eq!(
3510 replace_all_if_match("helloworld", &parsed.search, &parsed.replace),
3511 None
3512 );
3513 }
3514
3515 #[test]
3516 fn test_multiple_matches() {
3517 let re_str = r"a+b+";
3518 let search_config = SearchConfig {
3519 search_text: re_str,
3520 fixed_strings: false,
3521 match_whole_word: true,
3522 match_case: false,
3523 replacement_text: "X",
3524 advanced_regex: false,
3525 multiline: false,
3526 interpret_escape_sequences: false,
3527 };
3528 let parsed = test_helpers::must_parse_search_config(search_config);
3529 assert_eq!(
3530 replace_all_if_match("foo aab abb", &parsed.search, &parsed.replace),
3531 Some("foo X X".to_string())
3532 );
3533 let search_config = SearchConfig {
3534 search_text: re_str,
3535 fixed_strings: false,
3536 match_whole_word: true,
3537 match_case: false,
3538 replacement_text: "X",
3539 advanced_regex: false,
3540 multiline: false,
3541 interpret_escape_sequences: false,
3542 };
3543 let parsed = test_helpers::must_parse_search_config(search_config);
3544 assert_eq!(
3545 replace_all_if_match("ab abaab abb", &parsed.search, &parsed.replace),
3546 Some("X abaab X".to_string())
3547 );
3548 let search_config = SearchConfig {
3549 search_text: re_str,
3550 fixed_strings: false,
3551 match_whole_word: true,
3552 match_case: false,
3553 replacement_text: "X",
3554 advanced_regex: false,
3555 multiline: false,
3556 interpret_escape_sequences: false,
3557 };
3558 let parsed = test_helpers::must_parse_search_config(search_config);
3559 assert_eq!(
3560 replace_all_if_match("ababaababb", &parsed.search, &parsed.replace),
3561 None
3562 );
3563 let search_config = SearchConfig {
3564 search_text: re_str,
3565 fixed_strings: false,
3566 match_whole_word: true,
3567 match_case: false,
3568 replacement_text: "X",
3569 advanced_regex: false,
3570 multiline: false,
3571 interpret_escape_sequences: false,
3572 };
3573 let parsed = test_helpers::must_parse_search_config(search_config);
3574 assert_eq!(
3575 replace_all_if_match("ab ab aab abb", &parsed.search, &parsed.replace),
3576 Some("X X X X".to_string())
3577 );
3578 }
3579
3580 #[test]
3581 fn test_boundary_cases() {
3582 let re_str = r"foo\s*bar";
3583 let search_config = SearchConfig {
3585 search_text: re_str,
3586 fixed_strings: false,
3587 match_whole_word: true,
3588 match_case: false,
3589 replacement_text: "TEST",
3590 advanced_regex: false,
3591 multiline: false,
3592 interpret_escape_sequences: false,
3593 };
3594 let parsed = test_helpers::must_parse_search_config(search_config);
3595 assert_eq!(
3596 replace_all_if_match("foo bar baz", &parsed.search, &parsed.replace),
3597 Some("TEST baz".to_string())
3598 );
3599 let search_config = SearchConfig {
3601 search_text: re_str,
3602 fixed_strings: false,
3603 match_whole_word: true,
3604 match_case: false,
3605 replacement_text: "TEST",
3606 advanced_regex: false,
3607 multiline: false,
3608 interpret_escape_sequences: false,
3609 };
3610 let parsed = test_helpers::must_parse_search_config(search_config);
3611 assert_eq!(
3612 replace_all_if_match("baz foo bar", &parsed.search, &parsed.replace),
3613 Some("baz TEST".to_string())
3614 );
3615 let search_config = SearchConfig {
3617 search_text: re_str,
3618 fixed_strings: false,
3619 match_whole_word: true,
3620 match_case: false,
3621 replacement_text: "TEST",
3622 advanced_regex: false,
3623 multiline: false,
3624 interpret_escape_sequences: false,
3625 };
3626 let parsed = test_helpers::must_parse_search_config(search_config);
3627 assert_eq!(
3628 replace_all_if_match("a (?( foo bar)", &parsed.search, &parsed.replace),
3629 Some("a (?( TEST)".to_string())
3630 );
3631 }
3632
3633 #[test]
3634 fn test_with_punctuation() {
3635 let re_str = r"a\d+b";
3636 let search_config = SearchConfig {
3637 search_text: re_str,
3638 fixed_strings: false,
3639 match_whole_word: true,
3640 match_case: false,
3641 replacement_text: "X",
3642 advanced_regex: false,
3643 multiline: false,
3644 interpret_escape_sequences: false,
3645 };
3646 let parsed = test_helpers::must_parse_search_config(search_config);
3647 assert_eq!(
3648 replace_all_if_match("(a42b)", &parsed.search, &parsed.replace),
3649 Some("(X)".to_string())
3650 );
3651 let search_config = SearchConfig {
3652 search_text: re_str,
3653 fixed_strings: false,
3654 match_whole_word: true,
3655 match_case: false,
3656 replacement_text: "X",
3657 advanced_regex: false,
3658 multiline: false,
3659 interpret_escape_sequences: false,
3660 };
3661 let parsed = test_helpers::must_parse_search_config(search_config);
3662 assert_eq!(
3663 replace_all_if_match("foo.a123b!bar", &parsed.search, &parsed.replace),
3664 Some("foo.X!bar".to_string())
3665 );
3666 }
3667
3668 #[test]
3669 fn test_complex_patterns() {
3670 let re_str = r"[a-z]+\d+[a-z]+";
3671 let search_config = SearchConfig {
3672 search_text: re_str,
3673 fixed_strings: false,
3674 match_whole_word: true,
3675 match_case: false,
3676 replacement_text: "NEW",
3677 advanced_regex: false,
3678 multiline: false,
3679 interpret_escape_sequences: false,
3680 };
3681 let parsed = test_helpers::must_parse_search_config(search_config);
3682 assert_eq!(
3683 replace_all_if_match("test9 abc123def 8xyz", &parsed.search, &parsed.replace),
3684 Some("test9 NEW 8xyz".to_string())
3685 );
3686 let search_config = SearchConfig {
3687 search_text: re_str,
3688 fixed_strings: false,
3689 match_whole_word: true,
3690 match_case: false,
3691 replacement_text: "NEW",
3692 advanced_regex: false,
3693 multiline: false,
3694 interpret_escape_sequences: false,
3695 };
3696 let parsed = test_helpers::must_parse_search_config(search_config);
3697 assert_eq!(
3698 replace_all_if_match("test9abc123def8xyz", &parsed.search, &parsed.replace),
3699 None
3700 );
3701 }
3702
3703 #[test]
3704 fn test_optional_patterns() {
3705 let re_str = r"colou?r";
3706 let search_config = SearchConfig {
3707 search_text: re_str,
3708 fixed_strings: false,
3709 match_whole_word: true,
3710 match_case: false,
3711 replacement_text: "X",
3712 advanced_regex: false,
3713 multiline: false,
3714 interpret_escape_sequences: false,
3715 };
3716 let parsed = test_helpers::must_parse_search_config(search_config);
3717 assert_eq!(
3718 replace_all_if_match("my color and colour", &parsed.search, &parsed.replace),
3719 Some("my X and X".to_string())
3720 );
3721 }
3722
3723 #[test]
3724 fn test_empty_haystack() {
3725 let re_str = r"test";
3726 let search_config = SearchConfig {
3727 search_text: re_str,
3728 fixed_strings: false,
3729 match_whole_word: true,
3730 match_case: false,
3731 replacement_text: "NEW",
3732 advanced_regex: false,
3733 multiline: false,
3734 interpret_escape_sequences: false,
3735 };
3736 let parsed = test_helpers::must_parse_search_config(search_config);
3737 assert_eq!(
3738 replace_all_if_match("", &parsed.search, &parsed.replace),
3739 None
3740 );
3741 }
3742
3743 #[test]
3744 fn test_empty_search_regex() {
3745 let re_str = r"";
3746 let search_config = SearchConfig {
3747 search_text: re_str,
3748 fixed_strings: false,
3749 match_whole_word: true,
3750 match_case: false,
3751 replacement_text: "NEW",
3752 advanced_regex: false,
3753 multiline: false,
3754 interpret_escape_sequences: false,
3755 };
3756 let parsed = test_helpers::must_parse_search_config(search_config);
3757 assert_eq!(
3758 replace_all_if_match("search", &parsed.search, &parsed.replace),
3759 None
3760 );
3761 }
3762
3763 #[test]
3764 fn test_single_char() {
3765 let re_str = r"a";
3766 let search_config = SearchConfig {
3767 search_text: re_str,
3768 fixed_strings: false,
3769 match_whole_word: true,
3770 match_case: false,
3771 replacement_text: "X",
3772 advanced_regex: false,
3773 multiline: false,
3774 interpret_escape_sequences: false,
3775 };
3776 let parsed = test_helpers::must_parse_search_config(search_config);
3777 assert_eq!(
3778 replace_all_if_match("b a c", &parsed.search, &parsed.replace),
3779 Some("b X c".to_string())
3780 );
3781 let search_config = SearchConfig {
3782 search_text: re_str,
3783 fixed_strings: false,
3784 match_whole_word: true,
3785 match_case: false,
3786 replacement_text: "X",
3787 advanced_regex: false,
3788 multiline: false,
3789 interpret_escape_sequences: false,
3790 };
3791 let parsed = test_helpers::must_parse_search_config(search_config);
3792 assert_eq!(
3793 replace_all_if_match("bac", &parsed.search, &parsed.replace),
3794 None
3795 );
3796 }
3797
3798 #[test]
3799 fn test_escaped_chars() {
3800 let re_str = r"\(\d+\)";
3801 let search_config = SearchConfig {
3802 search_text: re_str,
3803 fixed_strings: false,
3804 match_whole_word: true,
3805 match_case: false,
3806 replacement_text: "X",
3807 advanced_regex: false,
3808 multiline: false,
3809 interpret_escape_sequences: false,
3810 };
3811 let parsed = test_helpers::must_parse_search_config(search_config);
3812 assert_eq!(
3813 replace_all_if_match("test (123) foo", &parsed.search, &parsed.replace),
3814 Some("test X foo".to_string())
3815 );
3816 }
3817
3818 #[test]
3819 fn test_with_unicode() {
3820 let re_str = r"λ\d+";
3821 let search_config = SearchConfig {
3822 search_text: re_str,
3823 fixed_strings: false,
3824 match_whole_word: true,
3825 match_case: false,
3826 replacement_text: "X",
3827 advanced_regex: false,
3828 multiline: false,
3829 interpret_escape_sequences: false,
3830 };
3831 let parsed = test_helpers::must_parse_search_config(search_config);
3832 assert_eq!(
3833 replace_all_if_match("calc λ123 β", &parsed.search, &parsed.replace),
3834 Some("calc X β".to_string())
3835 );
3836 let search_config = SearchConfig {
3837 search_text: re_str,
3838 fixed_strings: false,
3839 match_whole_word: true,
3840 match_case: false,
3841 replacement_text: "X",
3842 advanced_regex: false,
3843 multiline: false,
3844 interpret_escape_sequences: false,
3845 };
3846 let parsed = test_helpers::must_parse_search_config(search_config);
3847 assert_eq!(
3848 replace_all_if_match("calcλ123", &parsed.search, &parsed.replace),
3849 None
3850 );
3851 }
3852 }
3853
3854 #[cfg(unix)]
3855 mod permission_preservation_tests {
3856 use std::os::unix::fs::PermissionsExt;
3857
3858 use super::*;
3859
3860 const MODE_PERMISSIONS_MASK: u32 = 0o777;
3861
3862 fn assert_permissions_preserved(file_path: &Path, expected_mode: u32) {
3863 let final_perms = std::fs::metadata(file_path).unwrap().permissions();
3864 assert_eq!(final_perms.mode() & MODE_PERMISSIONS_MASK, expected_mode);
3865 }
3866
3867 #[test]
3868 fn test_replace_in_file_preserves_permissions() {
3869 let temp_dir = TempDir::new().unwrap();
3870 let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
3871 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3872
3873 let mut results = vec![create_search_result_with_replacement(
3874 file_path.to_str().unwrap(),
3875 1,
3876 "old text",
3877 LineEnding::Lf,
3878 "new text",
3879 true,
3880 None,
3881 )];
3882
3883 replace_in_file(&mut results).unwrap();
3884 assert_permissions_preserved(&file_path, 0o644);
3885 }
3886
3887 #[test]
3888 fn test_replace_in_memory_preserves_permissions() {
3889 let temp_dir = TempDir::new().unwrap();
3890 let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
3891 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755)).unwrap();
3892
3893 let result = replace_in_memory(&file_path, &fixed_search("old"), "new").unwrap();
3894 assert!(result);
3895 assert_permissions_preserved(&file_path, 0o755);
3896 }
3897
3898 #[test]
3899 fn test_replace_preserves_restrictive_permissions() {
3900 let temp_dir = TempDir::new().unwrap();
3901 let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
3902 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3903
3904 let mut results = vec![create_search_result_with_replacement(
3905 file_path.to_str().unwrap(),
3906 1,
3907 "old text",
3908 LineEnding::Lf,
3909 "new text",
3910 true,
3911 None,
3912 )];
3913
3914 replace_in_file(&mut results).unwrap();
3915 assert_permissions_preserved(&file_path, 0o600);
3916 }
3917
3918 #[test]
3919 fn test_replace_preserves_permissive_permissions() {
3920 let temp_dir = TempDir::new().unwrap();
3921 let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
3922 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o777)).unwrap();
3923
3924 let mut results = vec![create_search_result_with_replacement(
3925 file_path.to_str().unwrap(),
3926 1,
3927 "old text",
3928 LineEnding::Lf,
3929 "new text",
3930 true,
3931 None,
3932 )];
3933
3934 replace_in_file(&mut results).unwrap();
3935 assert_permissions_preserved(&file_path, 0o777);
3936 }
3937 }
3938
3939 mod multiline_replace_tests {
3940 use super::*;
3941 use crate::search::{ByteRangeParams, Line, search_multiline};
3942
3943 fn create_single_line_byte_range_result(
3948 path: &Path,
3949 line_number: usize,
3950 line_content: &str,
3951 match_start: usize,
3952 match_end: usize,
3953 byte_start: usize,
3954 replacement: &str,
3955 ) -> SearchResultWithReplacement {
3956 let expected_content = line_content[match_start..match_end].to_string();
3957 let byte_end = byte_start + expected_content.len();
3958
3959 SearchResultWithReplacement {
3960 search_result: SearchResult::new_byte_range(ByteRangeParams {
3961 path: Some(path.to_path_buf()),
3962 lines: vec![(
3963 line_number,
3964 Line {
3965 content: line_content.to_string(),
3966 line_ending: LineEnding::Lf,
3967 },
3968 )],
3969 match_start_in_first_line: match_start,
3970 match_end_in_last_line: match_end,
3971 byte_start,
3972 byte_end,
3973 content: expected_content,
3974 included: true,
3975 }),
3976 replacement: replacement.to_string(),
3977 replace_result: None,
3978 preview_error: None,
3979 }
3980 }
3981
3982 fn create_byte_range_result(
3986 path: &str,
3987 start_line: usize,
3988 end_line: usize,
3989 byte_start: usize,
3990 byte_end: usize,
3991 content: &str,
3992 replacement: &str,
3993 ) -> SearchResultWithReplacement {
3994 let mut lines: Vec<(usize, Line)> = Vec::new();
3996 let mut line_num = start_line;
3997 let mut remaining = content;
3998
3999 while !remaining.is_empty() {
4000 let (content, line_ending, rest) = if let Some(crlf_pos) = remaining.find("\r\n") {
4001 let lf_pos = remaining.find('\n');
4002 if let Some(pos) = lf_pos
4004 && pos < crlf_pos
4005 {
4006 (&remaining[..pos], LineEnding::Lf, &remaining[pos + 1..])
4007 } else {
4008 (
4009 &remaining[..crlf_pos],
4010 LineEnding::CrLf,
4011 &remaining[crlf_pos + 2..],
4012 )
4013 }
4014 } else if let Some(pos) = remaining.find('\n') {
4015 (&remaining[..pos], LineEnding::Lf, &remaining[pos + 1..])
4016 } else {
4017 (remaining, LineEnding::None, "")
4019 };
4020
4021 lines.push((
4022 line_num,
4023 Line {
4024 content: content.to_string(),
4025 line_ending,
4026 },
4027 ));
4028 line_num += 1;
4029 remaining = rest;
4030 }
4031
4032 let computed_end_line = start_line + lines.len() - 1;
4034 assert_eq!(
4035 computed_end_line,
4036 end_line,
4037 "Line count mismatch: content has {} lines (ending at line {}), but end_line was {}",
4038 lines.len(),
4039 computed_end_line,
4040 end_line
4041 );
4042
4043 let match_start_in_first_line = 0;
4046 let match_end_in_last_line = if let Some(last_line) = lines.last() {
4047 last_line.1.content.len()
4048 } else {
4049 0
4050 };
4051
4052 SearchResultWithReplacement {
4053 search_result: SearchResult::new_byte_range(ByteRangeParams {
4054 path: Some(PathBuf::from(path)),
4055 lines,
4056 match_start_in_first_line,
4057 match_end_in_last_line,
4058 byte_start,
4059 byte_end,
4060 content: content.to_string(),
4061 included: true,
4062 }),
4063 replacement: replacement.to_string(),
4064 replace_result: None,
4065 preview_error: None,
4066 }
4067 }
4068
4069 fn create_search_result_with_replacement(
4072 path: &str,
4073 start_line: usize,
4074 lines_content: &[(&str, LineEnding)],
4075 replacement: &str,
4076 ) -> SearchResultWithReplacement {
4077 use std::io::{BufRead, BufReader};
4078
4079 let file = std::fs::File::open(path).expect("Failed to open test file");
4080 let reader = BufReader::new(file);
4081
4082 let mut byte_start = 0;
4083 let mut current_line = 1;
4084
4085 for line_result in reader.lines() {
4087 if current_line >= start_line {
4088 break;
4089 }
4090 let line = line_result.expect("Failed to read line");
4091 byte_start += line.len() + 1; current_line += 1;
4093 }
4094
4095 let content = lines_content
4097 .iter()
4098 .fold(String::new(), |mut acc, (content, ending)| {
4099 use std::fmt::Write;
4100 write!(acc, "{}{}", content, ending.as_str()).unwrap();
4101 acc
4102 });
4103
4104 let byte_end = byte_start + content.len();
4105 let end_line = start_line + lines_content.len() - 1;
4106
4107 create_byte_range_result(
4108 path,
4109 start_line,
4110 end_line,
4111 byte_start,
4112 byte_end,
4113 &content,
4114 replacement,
4115 )
4116 }
4117
4118 #[test]
4119 fn test_single_multiline_replacement() {
4120 let temp_dir = TempDir::new().unwrap();
4121 let file_path = create_test_file(
4124 &temp_dir,
4125 "test.txt",
4126 "line 1\nline 2\nline 3\nline 4\nline 5\n",
4127 );
4128
4129 let mut results = vec![create_byte_range_result(
4131 file_path.to_str().unwrap(),
4132 2,
4133 4,
4134 7,
4135 28,
4136 "line 2\nline 3\nline 4\n",
4137 "REPLACED\n",
4138 )];
4139
4140 let result = replace_in_file(&mut results);
4141 assert!(result.is_ok());
4142 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4143
4144 assert_file_content(&file_path, "line 1\nREPLACED\nline 5\n");
4145 }
4146
4147 #[test]
4148 fn test_non_overlapping_multiline_replacements() {
4149 let temp_dir = TempDir::new().unwrap();
4150 let file_path = create_test_file(
4153 &temp_dir,
4154 "test.txt",
4155 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
4156 );
4157
4158 let mut results = vec![
4159 create_byte_range_result(
4161 file_path.to_str().unwrap(),
4162 1,
4163 2,
4164 0,
4165 14,
4166 "line 1\nline 2\n",
4167 "FIRST\n",
4168 ),
4169 create_byte_range_result(
4171 file_path.to_str().unwrap(),
4172 5,
4173 7,
4174 28,
4175 49,
4176 "line 5\nline 6\nline 7\n",
4177 "SECOND\n",
4178 ),
4179 ];
4180
4181 let result = replace_in_file(&mut results);
4182 assert!(result.is_ok());
4183 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4184 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4185
4186 assert_file_content(&file_path, "FIRST\nline 3\nline 4\nSECOND\n");
4187 }
4188
4189 #[test]
4190 fn test_conflict_overlapping_ranges() {
4191 let temp_dir = TempDir::new().unwrap();
4192 let file_path = create_test_file(
4195 &temp_dir,
4196 "test.txt",
4197 "line 1\nline 2\nline 3\nline 4\nline 5\n",
4198 );
4199
4200 let mut results = vec![
4203 create_byte_range_result(
4204 file_path.to_str().unwrap(),
4205 2,
4206 4,
4207 7,
4208 28,
4209 "line 2\nline 3\nline 4\n",
4210 "FIRST\n",
4211 ),
4212 create_byte_range_result(
4213 file_path.to_str().unwrap(),
4214 3,
4215 5,
4216 14,
4217 35,
4218 "line 3\nline 4\nline 5\n",
4219 "SECOND\n",
4220 ),
4221 ];
4222
4223 let result = replace_in_file(&mut results);
4224 assert!(result.is_ok());
4225
4226 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4228 assert!(matches!(
4229 results[1].replace_result,
4230 Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
4231 ));
4232
4233 assert_file_content(&file_path, "line 1\nFIRST\nline 5\n");
4235 }
4236
4237 #[test]
4238 fn test_multiple_overlapping_conflicts() {
4239 let temp_dir = TempDir::new().unwrap();
4240 let file_content = (1..=15)
4246 .map(|i| format!("line {i}"))
4247 .collect::<Vec<_>>()
4248 .join("\n")
4249 + "\n";
4250 let file_path = create_test_file(&temp_dir, "test.txt", &file_content);
4251
4252 let mut results = vec![
4253 create_byte_range_result(
4255 file_path.to_str().unwrap(),
4256 9,
4257 11,
4258 56,
4259 79,
4260 "line 9\nline 10\nline 11\n",
4261 "FIRST\n",
4262 ),
4263 create_byte_range_result(
4265 file_path.to_str().unwrap(),
4266 10,
4267 13,
4268 63,
4269 95,
4270 "line 10\nline 11\nline 12\nline 13\n",
4271 "SECOND\n",
4272 ),
4273 create_byte_range_result(
4275 file_path.to_str().unwrap(),
4276 12,
4277 12,
4278 79,
4279 87,
4280 "line 12\n",
4281 "THIRD\n",
4282 ),
4283 ];
4284
4285 let result = replace_in_file(&mut results);
4286 assert!(result.is_ok());
4287
4288 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4290 assert!(matches!(
4292 results[1].replace_result,
4293 Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
4294 ));
4295 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4297
4298 let expected = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nFIRST\nTHIRD\nline 13\nline 14\nline 15\n";
4299 assert_file_content(&file_path, expected);
4300 }
4301
4302 #[test]
4303 fn test_conflict_detection_byte_offsets_no_overlap() {
4304 let temp_dir = TempDir::new().unwrap();
4306 let file_path = create_test_file(&temp_dir, "test.txt", "abc def ghi\n");
4307 let line_content = "abc def ghi";
4308
4309 let mut results = vec![
4310 create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
4311 create_single_line_byte_range_result(&file_path, 1, line_content, 4, 7, 4, "YYY"),
4312 create_single_line_byte_range_result(&file_path, 1, line_content, 8, 11, 8, "ZZZ"),
4313 ];
4314
4315 let result = replace_in_file(&mut results);
4316 assert!(result.is_ok());
4317
4318 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4320 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4321 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4322
4323 assert_file_content(&file_path, "XXX YYY ZZZ\n");
4325 }
4326
4327 #[test]
4328 fn test_conflict_detection_byte_offsets_with_overlap() {
4329 let temp_dir = TempDir::new().unwrap();
4331 let file_path = create_test_file(&temp_dir, "test.txt", "abcdef\n");
4332 let line_content = "abcdef";
4333
4334 let mut results = vec![
4335 create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
4336 create_single_line_byte_range_result(&file_path, 1, line_content, 2, 6, 2, "YYY"),
4337 ];
4338
4339 let result = replace_in_file(&mut results);
4340 assert!(result.is_ok());
4341
4342 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4344 assert_eq!(
4346 results[1].replace_result,
4347 Some(ReplaceResult::Error(
4348 "Conflicts with previous replacement".to_owned()
4349 ))
4350 );
4351 assert_file_content(&file_path, "XXXdef\n");
4353 }
4354
4355 #[test]
4356 fn test_conflict_detection_byte_offsets_adjacent() {
4357 let temp_dir = TempDir::new().unwrap();
4361 let file_path = create_test_file(&temp_dir, "test.txt", "abcdef\n");
4362 let line_content = "abcdef";
4363
4364 let mut results = vec![
4365 create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
4366 create_single_line_byte_range_result(&file_path, 1, line_content, 2, 6, 2, "YYY"),
4367 ];
4368
4369 let result = replace_in_file(&mut results);
4370 assert!(result.is_ok());
4371
4372 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4374 assert_eq!(
4376 results[1].replace_result,
4377 Some(ReplaceResult::Error(
4378 "Conflicts with previous replacement".to_owned()
4379 ))
4380 );
4381 assert_file_content(&file_path, "XXXdef\n");
4383 }
4384
4385 #[test]
4386 fn test_conflict_detection_line_level_adjacent() {
4387 let temp_dir = TempDir::new().unwrap();
4389 let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\nline 3\n");
4390
4391 let mut results = vec![
4392 SearchResultWithReplacement {
4393 search_result: SearchResult::new_line(
4394 Some(file_path.clone()),
4395 1,
4396 "line 1".to_string(),
4397 LineEnding::Lf,
4398 true,
4399 ),
4400 replacement: "XXX\n".to_string(),
4401 replace_result: None,
4402 preview_error: None,
4403 },
4404 SearchResultWithReplacement {
4405 search_result: SearchResult::new_line(
4406 Some(file_path),
4407 2,
4408 "line 2".to_string(),
4409 LineEnding::Lf,
4410 true,
4411 ),
4412 replacement: "YYY\n".to_string(),
4413 replace_result: None,
4414 preview_error: None,
4415 },
4416 ];
4417
4418 let result = replace_in_file(&mut results);
4419 assert!(result.is_ok());
4420
4421 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4423 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4424 }
4425
4426 #[test]
4427 fn test_adjacent_non_overlapping() {
4428 let temp_dir = TempDir::new().unwrap();
4429 let file_path = create_test_file(
4430 &temp_dir,
4431 "test.txt",
4432 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
4433 );
4434
4435 let mut results = vec![
4436 create_search_result_with_replacement(
4437 file_path.to_str().unwrap(),
4438 1,
4439 &[
4440 ("line 1", LineEnding::Lf),
4441 ("line 2", LineEnding::Lf),
4442 ("line 3", LineEnding::Lf),
4443 ],
4444 "FIRST\n",
4445 ),
4446 create_search_result_with_replacement(
4447 file_path.to_str().unwrap(),
4448 4,
4449 &[
4450 ("line 4", LineEnding::Lf),
4451 ("line 5", LineEnding::Lf),
4452 ("line 6", LineEnding::Lf),
4453 ],
4454 "SECOND\n",
4455 ),
4456 ];
4457
4458 let result = replace_in_file(&mut results);
4459 assert!(result.is_ok());
4460 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4461 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4462
4463 assert_file_content(&file_path, "FIRST\nSECOND\nline 7\n");
4464 }
4465
4466 #[test]
4467 fn test_partial_overlap() {
4468 let temp_dir = TempDir::new().unwrap();
4469 let file_path = create_test_file(
4470 &temp_dir,
4471 "test.txt",
4472 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n",
4473 );
4474
4475 let mut results = vec![
4476 create_search_result_with_replacement(
4477 file_path.to_str().unwrap(),
4478 1,
4479 &[
4480 ("line 1", LineEnding::Lf),
4481 ("line 2", LineEnding::Lf),
4482 ("line 3", LineEnding::Lf),
4483 ("line 4", LineEnding::Lf),
4484 ("line 5", LineEnding::Lf),
4485 ],
4486 "FIRST\n",
4487 ),
4488 create_search_result_with_replacement(
4489 file_path.to_str().unwrap(),
4490 3,
4491 &[
4492 ("line 3", LineEnding::Lf),
4493 ("line 4", LineEnding::Lf),
4494 ("line 5", LineEnding::Lf),
4495 ("line 6", LineEnding::Lf),
4496 ("line 7", LineEnding::Lf),
4497 ("line 8", LineEnding::Lf),
4498 ],
4499 "SECOND\n",
4500 ),
4501 ];
4502
4503 let result = replace_in_file(&mut results);
4504 assert!(result.is_ok());
4505 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4506 assert!(matches!(
4507 results[1].replace_result,
4508 Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
4509 ));
4510
4511 assert_file_content(&file_path, "FIRST\nline 6\nline 7\nline 8\n");
4512 }
4513
4514 #[test]
4515 fn test_single_line_between_multiline() {
4516 let temp_dir = TempDir::new().unwrap();
4517 let file_path = create_test_file(
4518 &temp_dir,
4519 "test.txt",
4520 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
4521 );
4522
4523 let mut results = vec![
4524 create_search_result_with_replacement(
4525 file_path.to_str().unwrap(),
4526 1,
4527 &[
4528 ("line 1", LineEnding::Lf),
4529 ("line 2", LineEnding::Lf),
4530 ("line 3", LineEnding::Lf),
4531 ],
4532 "FIRST\n",
4533 ),
4534 create_search_result_with_replacement(
4535 file_path.to_str().unwrap(),
4536 2,
4537 &[("line 2", LineEnding::Lf)],
4538 "MIDDLE\n",
4539 ),
4540 create_search_result_with_replacement(
4541 file_path.to_str().unwrap(),
4542 4,
4543 &[
4544 ("line 4", LineEnding::Lf),
4545 ("line 5", LineEnding::Lf),
4546 ("line 6", LineEnding::Lf),
4547 ],
4548 "LAST\n",
4549 ),
4550 ];
4551
4552 let result = replace_in_file(&mut results);
4553 assert!(result.is_ok());
4554 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4555 assert!(matches!(
4556 results[1].replace_result,
4557 Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
4558 ));
4559 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4560
4561 assert_file_content(&file_path, "FIRST\nLAST\nline 7\n");
4562 }
4563
4564 #[test]
4565 fn test_multiline_at_end_of_file() {
4566 let temp_dir = TempDir::new().unwrap();
4567 let file_path = create_test_file(
4568 &temp_dir,
4569 "test.txt",
4570 "line 1\nline 2\nline 3\nline 4\nline 5",
4571 );
4572
4573 let mut results = vec![create_search_result_with_replacement(
4574 file_path.to_str().unwrap(),
4575 3,
4576 &[
4577 ("line 3", LineEnding::Lf),
4578 ("line 4", LineEnding::Lf),
4579 ("line 5", LineEnding::None),
4580 ],
4581 "END", )];
4583
4584 let result = replace_in_file(&mut results);
4585 assert!(result.is_ok());
4586 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4587
4588 assert_file_content(&file_path, "line 1\nline 2\nEND");
4589 }
4590
4591 #[test]
4592 fn test_multiline_no_newline_in_replacement() {
4593 let temp_dir = TempDir::new().unwrap();
4594 let file_path = create_test_file(
4595 &temp_dir,
4596 "test.txt",
4597 "line 1\nline 2\nline 3\nline 4\nline 5",
4598 );
4599
4600 let mut results = vec![create_search_result_with_replacement(
4601 file_path.to_str().unwrap(),
4602 2,
4603 &[
4604 ("line 2", LineEnding::Lf),
4605 ("line 3", LineEnding::Lf),
4606 ("line 4", LineEnding::Lf),
4607 ],
4608 "REPLACEMENT",
4609 )];
4610
4611 let result = replace_in_file(&mut results);
4612 assert!(result.is_ok());
4613 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4614
4615 assert_file_content(&file_path, "line 1\nREPLACEMENTline 5");
4617 }
4618
4619 #[test]
4620 fn test_multiple_multiline_with_gaps() {
4621 let temp_dir = TempDir::new().unwrap();
4622 let file_content = (1..=15)
4623 .map(|i| format!("line {i}"))
4624 .collect::<Vec<_>>()
4625 .join("\n")
4626 + "\n";
4627 let file_path = create_test_file(&temp_dir, "test.txt", &file_content);
4628
4629 let mut results = vec![
4630 create_search_result_with_replacement(
4631 file_path.to_str().unwrap(),
4632 1,
4633 &[("line 1", LineEnding::Lf), ("line 2", LineEnding::Lf)],
4634 "A\n",
4635 ),
4636 create_search_result_with_replacement(
4637 file_path.to_str().unwrap(),
4638 5,
4639 &[
4640 ("line 5", LineEnding::Lf),
4641 ("line 6", LineEnding::Lf),
4642 ("line 7", LineEnding::Lf),
4643 ],
4644 "B\n",
4645 ),
4646 create_search_result_with_replacement(
4647 file_path.to_str().unwrap(),
4648 10,
4649 &[
4650 ("line 10", LineEnding::Lf),
4651 ("line 11", LineEnding::Lf),
4652 ("line 12", LineEnding::Lf),
4653 ],
4654 "C\n",
4655 ),
4656 ];
4657
4658 let result = replace_in_file(&mut results);
4659 assert!(result.is_ok());
4660 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4661 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4662 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4663
4664 let expected = "A\nline 3\nline 4\nB\nline 8\nline 9\nC\nline 13\nline 14\nline 15\n";
4665 assert_file_content(&file_path, expected);
4666 }
4667
4668 #[test]
4669 fn test_file_changed_multiline_validation() {
4670 let temp_dir = TempDir::new().unwrap();
4671 let file_path =
4672 create_test_file(&temp_dir, "test.txt", "line 1\nCHANGED\nline 3\nline 4\n");
4673
4674 let mut results = vec![create_search_result_with_replacement(
4676 file_path.to_str().unwrap(),
4677 1,
4678 &[
4679 ("line 1", LineEnding::Lf),
4680 ("line 2", LineEnding::Lf),
4681 ("line 3", LineEnding::Lf),
4682 ],
4683 "REPLACED\n",
4684 )];
4685
4686 let result = replace_in_file(&mut results);
4687 assert!(result.is_ok());
4688 assert!(matches!(
4689 results[0].replace_result,
4690 Some(ReplaceResult::Error(ref msg)) if msg.contains("File changed")
4691 ));
4692
4693 assert_file_content(&file_path, "line 1\nCHANGED\nline 3\nline 4\n");
4695 }
4696
4697 #[test]
4698 fn test_file_too_short_multiline() {
4699 let temp_dir = TempDir::new().unwrap();
4700 let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\n");
4701
4702 let mut results = vec![create_search_result_with_replacement(
4704 file_path.to_str().unwrap(),
4705 1,
4706 &[
4707 ("line 1", LineEnding::Lf),
4708 ("line 2", LineEnding::Lf),
4709 ("line 3", LineEnding::Lf),
4710 ("line 4", LineEnding::Lf),
4711 ],
4712 "REPLACED\n",
4713 )];
4714
4715 let result = replace_in_file(&mut results);
4716 assert!(result.is_ok());
4717 assert!(results[0].replace_result.is_none());
4719
4720 assert_file_content(&file_path, "line 1\nline 2\n");
4722 }
4723
4724 #[test]
4725 fn test_mixed_single_and_multiline() {
4726 let temp_dir = TempDir::new().unwrap();
4727 let file_path = create_test_file(
4728 &temp_dir,
4729 "test.txt",
4730 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\n",
4731 );
4732
4733 let mut results = vec![
4734 create_search_result_with_replacement(
4735 file_path.to_str().unwrap(),
4736 1,
4737 &[("line 1", LineEnding::Lf)],
4738 "SINGLE\n",
4739 ),
4740 create_search_result_with_replacement(
4741 file_path.to_str().unwrap(),
4742 3,
4743 &[
4744 ("line 3", LineEnding::Lf),
4745 ("line 4", LineEnding::Lf),
4746 ("line 5", LineEnding::Lf),
4747 ],
4748 "MULTI\n",
4749 ),
4750 create_search_result_with_replacement(
4751 file_path.to_str().unwrap(),
4752 6,
4753 &[("line 6", LineEnding::Lf)],
4754 "SINGLE2\n",
4755 ),
4756 ];
4757
4758 let result = replace_in_file(&mut results);
4759 assert!(result.is_ok());
4760 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4761 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4762 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4763
4764 assert_file_content(&file_path, "SINGLE\nline 2\nMULTI\nSINGLE2\n");
4765 }
4766
4767 #[test]
4768 fn test_unsorted_input() {
4769 let temp_dir = TempDir::new().unwrap();
4770 let file_path = create_test_file(
4771 &temp_dir,
4772 "test.txt",
4773 "line 1\nline 2\nline 3\nline 4\nline 5\n",
4774 );
4775
4776 let mut results = vec![
4779 create_search_result_with_replacement(
4780 file_path.to_str().unwrap(),
4781 5,
4782 &[("line 5", LineEnding::Lf)],
4783 "LAST\n",
4784 ),
4785 create_search_result_with_replacement(
4786 file_path.to_str().unwrap(),
4787 1,
4788 &[("line 1", LineEnding::Lf), ("line 2", LineEnding::Lf)],
4789 "FIRST\n",
4790 ),
4791 ];
4792
4793 let result = replace_in_file(&mut results);
4794 assert!(result.is_ok());
4795 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4796 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4797
4798 assert_file_content(&file_path, "FIRST\nline 3\nline 4\nLAST\n");
4799 }
4800
4801 #[test]
4802 fn test_multiple_matches_same_line_no_conflict() {
4803 let temp_dir = TempDir::new().unwrap();
4809 let file_path =
4810 create_test_file(&temp_dir, "test.txt", "foo\nbar baz bar qux\nbar\nbux\n");
4811
4812 let content = std::fs::read_to_string(&file_path).unwrap();
4814 let search = SearchType::Fixed("bar".to_string());
4815 let search_results = search_multiline(&content, &search, Some(&file_path));
4816
4817 assert_eq!(search_results.len(), 3);
4819
4820 assert_eq!(search_results[0].start_line_number(), 2);
4822 assert_eq!(search_results[0].end_line_number(), 2);
4823 assert_eq!(byte_range_bytes(&search_results[0]), (4, 7));
4824 assert_eq!(byte_range_content(&search_results[0]), "bar");
4825
4826 assert_eq!(search_results[1].start_line_number(), 2);
4828 assert_eq!(search_results[1].end_line_number(), 2);
4829 assert_eq!(byte_range_bytes(&search_results[1]), (12, 15));
4830 assert_eq!(byte_range_content(&search_results[1]), "bar");
4831
4832 assert_eq!(search_results[2].start_line_number(), 3);
4834 assert_eq!(search_results[2].end_line_number(), 3);
4835 assert_eq!(byte_range_bytes(&search_results[2]), (20, 23));
4836 assert_eq!(byte_range_content(&search_results[2]), "bar");
4837
4838 let mut results: Vec<SearchResultWithReplacement> = search_results
4840 .into_iter()
4841 .map(|sr| SearchResultWithReplacement {
4842 search_result: sr,
4843 replacement: "REPLACED".to_string(),
4844 replace_result: None,
4845 preview_error: None,
4846 })
4847 .collect();
4848
4849 let result = replace_in_file(&mut results);
4851 assert!(result.is_ok());
4852
4853 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4855 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4856 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4857
4858 assert_file_content(
4860 &file_path,
4861 "foo\nREPLACED baz REPLACED qux\nREPLACED\nbux\n",
4862 );
4863 }
4864
4865 #[test]
4866 fn test_multiple_matches_same_line_all_replaced() {
4867 let temp_dir = TempDir::new().unwrap();
4870 let file_path =
4871 create_test_file(&temp_dir, "test.txt", "foo\nbar baz bar qux\nbar\nbux\n");
4872
4873 let content = std::fs::read_to_string(&file_path).unwrap();
4875 let search = SearchType::Fixed("bar".to_string());
4876 let search_results = search_multiline(&content, &search, Some(&file_path));
4877
4878 assert_eq!(search_results.len(), 3);
4880
4881 assert_eq!(byte_range_bytes(&search_results[0]), (4, 7));
4883 assert_eq!(byte_range_bytes(&search_results[1]), (12, 15));
4884 assert_eq!(byte_range_bytes(&search_results[2]), (20, 23));
4885
4886 let mut results: Vec<SearchResultWithReplacement> = search_results
4888 .into_iter()
4889 .map(|sr| SearchResultWithReplacement {
4890 search_result: sr,
4891 replacement: "REPLACED".to_string(),
4892 replace_result: None,
4893 preview_error: None,
4894 })
4895 .collect();
4896
4897 let result = replace_in_file(&mut results);
4898 assert!(result.is_ok());
4899
4900 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4902 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
4903 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
4904
4905 assert_file_content(
4907 &file_path,
4908 "foo\nREPLACED baz REPLACED qux\nREPLACED\nbux\n",
4909 );
4910 }
4911 }
4912
4913 mod mark_conflicting_replacements_tests {
4914 use super::{super::mark_conflicting_replacements, *};
4915 use crate::search::{ByteRangeParams, Line};
4916
4917 fn create_replacement_result(
4921 start_line: usize,
4922 end_line: usize,
4923 byte_start: usize,
4924 byte_end: usize,
4925 ) -> SearchResultWithReplacement {
4926 let content = format!("content-{byte_start}-{byte_end}");
4927 let lines: Vec<(usize, Line)> = (start_line..=end_line)
4928 .map(|line_num| {
4929 (
4930 line_num,
4931 Line {
4932 content: format!("line {line_num}"),
4933 line_ending: LineEnding::Lf,
4934 },
4935 )
4936 })
4937 .collect();
4938 let last_line_content_len = lines.last().map_or(0, |(_, l)| l.content.len());
4939 SearchResultWithReplacement {
4940 search_result: SearchResult::new_byte_range(ByteRangeParams {
4941 path: Some(PathBuf::from("test.txt")),
4942 lines,
4943 match_start_in_first_line: 0,
4944 match_end_in_last_line: last_line_content_len,
4945 byte_start,
4946 byte_end,
4947 content,
4948 included: true,
4949 }),
4950 replacement: "REPLACED".to_string(),
4951 replace_result: None,
4952 preview_error: None,
4953 }
4954 }
4955
4956 #[test]
4957 fn test_no_conflicts_sequential_byte_ranges() {
4958 let mut results = vec![
4960 create_replacement_result(1, 1, 0, 9),
4961 create_replacement_result(2, 2, 10, 19),
4962 create_replacement_result(3, 3, 20, 29),
4963 ];
4964
4965 mark_conflicting_replacements(&mut results);
4966
4967 assert_eq!(results.len(), 3);
4968 assert_eq!(results[0].replace_result, None);
4969 assert_eq!(results[1].replace_result, None);
4970 assert_eq!(results[2].replace_result, None);
4971 }
4972
4973 #[test]
4974 fn test_conflict_overlapping_byte_ranges() {
4975 let mut results = vec![
4977 create_replacement_result(1, 1, 0, 10),
4978 create_replacement_result(1, 1, 5, 15),
4979 ];
4980
4981 mark_conflicting_replacements(&mut results);
4982
4983 assert_eq!(results.len(), 2);
4984 assert_eq!(results[0].replace_result, None);
4985 assert_eq!(
4986 results[1].replace_result,
4987 Some(ReplaceResult::Error(
4988 "Conflicts with previous replacement".to_owned()
4989 ))
4990 );
4991 }
4992
4993 #[test]
4994 fn test_conflict_overlapping_multiline_byte_ranges() {
4995 let mut results = vec![
4999 create_replacement_result(1, 3, 0, 17),
5000 create_replacement_result(2, 4, 6, 23),
5001 ];
5002
5003 mark_conflicting_replacements(&mut results);
5004
5005 assert_eq!(results.len(), 2);
5006 assert_eq!(results[0].replace_result, None);
5007 assert_eq!(
5008 results[1].replace_result,
5009 Some(ReplaceResult::Error(
5010 "Conflicts with previous replacement".to_owned()
5011 ))
5012 );
5013 }
5014
5015 #[test]
5016 fn test_no_conflict_adjacent_multiline_byte_ranges() {
5017 let mut results = vec![
5019 create_replacement_result(1, 3, 0, 17),
5020 create_replacement_result(4, 6, 18, 35),
5021 ];
5022
5023 mark_conflicting_replacements(&mut results);
5024
5025 assert_eq!(results.len(), 2);
5026 assert_eq!(results[0].replace_result, None);
5027 assert_eq!(results[1].replace_result, None);
5028 }
5029
5030 #[test]
5031 fn test_byte_offsets_no_overlap_same_line() {
5032 let mut results = vec![
5033 create_replacement_result(1, 1, 0, 5),
5034 create_replacement_result(1, 1, 6, 10),
5035 create_replacement_result(1, 1, 11, 15),
5036 ];
5037
5038 mark_conflicting_replacements(&mut results);
5039
5040 assert_eq!(results.len(), 3);
5041 assert_eq!(results[0].replace_result, None);
5042 assert_eq!(results[1].replace_result, None);
5043 assert_eq!(results[2].replace_result, None);
5044 }
5045
5046 #[test]
5047 fn test_byte_offsets_touching_no_conflict() {
5048 let mut results = vec![
5050 create_replacement_result(1, 1, 0, 5),
5051 create_replacement_result(1, 1, 5, 10),
5052 ];
5053
5054 mark_conflicting_replacements(&mut results);
5055
5056 assert_eq!(results.len(), 2);
5057 assert_eq!(results[0].replace_result, None);
5059 assert_eq!(results[1].replace_result, None);
5060 }
5061
5062 #[test]
5063 fn test_byte_offsets_overlap_conflict() {
5064 let mut results = vec![
5065 create_replacement_result(1, 1, 0, 10),
5066 create_replacement_result(1, 1, 5, 15),
5067 ];
5068
5069 mark_conflicting_replacements(&mut results);
5070
5071 assert_eq!(results.len(), 2);
5072 assert_eq!(results[0].replace_result, None);
5073 assert_eq!(
5074 results[1].replace_result,
5075 Some(ReplaceResult::Error(
5076 "Conflicts with previous replacement".to_owned()
5077 ))
5078 );
5079 }
5080
5081 #[test]
5082 fn test_byte_offsets_across_lines() {
5083 let mut results = vec![
5084 create_replacement_result(1, 1, 0, 5),
5085 create_replacement_result(2, 2, 10, 15),
5086 create_replacement_result(2, 2, 16, 20),
5087 ];
5088
5089 mark_conflicting_replacements(&mut results);
5090
5091 assert_eq!(results.len(), 3);
5092 assert_eq!(results[0].replace_result, None);
5093 assert_eq!(results[1].replace_result, None);
5094 assert_eq!(results[2].replace_result, None);
5095 }
5096
5097 #[test]
5098 fn test_sorting_by_byte_offset() {
5099 let mut results = vec![
5101 create_replacement_result(2, 2, 10, 15),
5102 create_replacement_result(1, 1, 5, 8),
5103 create_replacement_result(1, 1, 0, 3),
5104 ];
5105
5106 mark_conflicting_replacements(&mut results);
5107
5108 assert_eq!(results.len(), 3);
5110 assert_eq!(byte_range_bytes(&results[0].search_result), (0, 3));
5111 assert_eq!(results[0].replace_result, None);
5112
5113 assert_eq!(byte_range_bytes(&results[1].search_result), (5, 8));
5114 assert_eq!(results[1].replace_result, None);
5115
5116 assert_eq!(byte_range_bytes(&results[2].search_result), (10, 15));
5117 assert_eq!(results[2].replace_result, None);
5118 }
5119
5120 #[test]
5121 fn test_chain_of_overlapping_conflicts() {
5122 let mut results = vec![
5124 create_replacement_result(1, 1, 0, 10),
5125 create_replacement_result(1, 1, 5, 15),
5126 create_replacement_result(1, 2, 10, 20),
5127 ];
5128
5129 mark_conflicting_replacements(&mut results);
5130
5131 assert_eq!(results.len(), 3);
5132 assert_eq!(results[0].replace_result, None);
5134 assert_eq!(
5136 results[1].replace_result,
5137 Some(ReplaceResult::Error(
5138 "Conflicts with previous replacement".to_owned()
5139 ))
5140 );
5141 assert_eq!(results[2].replace_result, None);
5143 }
5144
5145 #[test]
5146 fn test_empty_results() {
5147 let mut results: Vec<SearchResultWithReplacement> = vec![];
5148 mark_conflicting_replacements(&mut results);
5149 assert_eq!(results.len(), 0);
5150 }
5151
5152 #[test]
5153 fn test_single_result() {
5154 let mut results = vec![create_replacement_result(1, 1, 0, 10)];
5155 mark_conflicting_replacements(&mut results);
5156 assert_eq!(results.len(), 1);
5157 assert_eq!(results[0].replace_result, None);
5158 }
5159 }
5160
5161 mod byte_mode_replace_tests {
5162 use super::*;
5163 use crate::search::{ByteRangeParams, Line};
5164
5165 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
5166 let path = dir.path().join(name);
5167 std::fs::write(&path, content).unwrap();
5168 path
5169 }
5170
5171 fn assert_file_content(path: &Path, expected: &str) {
5172 let actual = std::fs::read_to_string(path).unwrap();
5173 assert_eq!(actual, expected, "File content mismatch");
5174 }
5175
5176 fn create_byte_range_result(
5177 path: &str,
5178 start_line: usize,
5179 end_line: usize,
5180 byte_start: usize,
5181 byte_end: usize,
5182 content: &str,
5183 replacement: &str,
5184 ) -> SearchResultWithReplacement {
5185 let lines: Vec<(usize, Line)> = (start_line..=end_line)
5186 .map(|line_num| {
5187 (
5188 line_num,
5189 Line {
5190 content: format!("line {line_num}"),
5191 line_ending: LineEnding::Lf,
5192 },
5193 )
5194 })
5195 .collect();
5196 let last_line_content_len = lines.last().map_or(0, |(_, l)| l.content.len());
5197 SearchResultWithReplacement {
5198 search_result: SearchResult::new_byte_range(ByteRangeParams {
5199 path: Some(PathBuf::from(path)),
5200 lines,
5201 match_start_in_first_line: 0,
5202 match_end_in_last_line: last_line_content_len,
5203 byte_start,
5204 byte_end,
5205 content: content.to_string(),
5206 included: true,
5207 }),
5208 replacement: replacement.to_string(),
5209 replace_result: None,
5210 preview_error: None,
5211 }
5212 }
5213
5214 #[test]
5215 fn test_byte_mode_happy_path_single_replacement() {
5216 let temp_dir = TempDir::new().unwrap();
5217 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5218
5219 let mut results = vec![create_byte_range_result(
5221 file_path.to_str().unwrap(),
5222 1,
5223 1,
5224 6,
5225 11,
5226 "world",
5227 "rust",
5228 )];
5229
5230 let result = replace_in_file(&mut results);
5231 assert!(result.is_ok());
5232 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5233 assert_file_content(&file_path, "hello rust");
5234 }
5235
5236 #[test]
5237 fn test_byte_mode_happy_path_multiple_replacements() {
5238 let temp_dir = TempDir::new().unwrap();
5239 let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz qux");
5241
5242 let mut results = vec![
5243 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
5244 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
5245 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 12, 15, "qux", "DDD"),
5246 ];
5247
5248 let result = replace_in_file(&mut results);
5249 assert!(result.is_ok());
5250 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5251 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
5252 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
5253 assert_file_content(&file_path, "AAA bar CCC DDD");
5254 }
5255
5256 #[test]
5257 fn test_byte_mode_replacement_at_start() {
5258 let temp_dir = TempDir::new().unwrap();
5259 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5260
5261 let mut results = vec![create_byte_range_result(
5263 file_path.to_str().unwrap(),
5264 1,
5265 1,
5266 0,
5267 5,
5268 "hello",
5269 "hi",
5270 )];
5271
5272 let result = replace_in_file(&mut results);
5273 assert!(result.is_ok());
5274 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5275 assert_file_content(&file_path, "hi world");
5276 }
5277
5278 #[test]
5279 fn test_byte_mode_zero_length_insertion() {
5280 let temp_dir = TempDir::new().unwrap();
5281 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5282
5283 let mut results = vec![SearchResultWithReplacement {
5284 search_result: SearchResult::new_byte_range(ByteRangeParams {
5285 path: Some(file_path.clone()),
5286 lines: vec![(
5287 1,
5288 Line {
5289 content: "hello world".to_string(),
5290 line_ending: LineEnding::Lf,
5291 },
5292 )],
5293 match_start_in_first_line: 5,
5294 match_end_in_last_line: 5,
5295 byte_start: 5,
5296 byte_end: 5, content: "".to_string(),
5298 included: true,
5299 }),
5300 replacement: "X".to_string(),
5301 replace_result: None,
5302 preview_error: None,
5303 }];
5304
5305 let result = replace_in_file(&mut results);
5306 assert!(result.is_ok());
5307 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5308 assert_file_content(&file_path, "helloX world");
5309 }
5310
5311 #[test]
5312 fn test_byte_mode_replacement_at_end() {
5313 let temp_dir = TempDir::new().unwrap();
5314 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5315
5316 let mut results = vec![create_byte_range_result(
5318 file_path.to_str().unwrap(),
5319 1,
5320 1,
5321 6,
5322 11,
5323 "world",
5324 "everyone",
5325 )];
5326
5327 let result = replace_in_file(&mut results);
5328 assert!(result.is_ok());
5329 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5330 assert_file_content(&file_path, "hello everyone");
5331 }
5332
5333 #[test]
5334 fn test_byte_mode_file_content_changed() {
5335 let temp_dir = TempDir::new().unwrap();
5336 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5337
5338 let mut results = vec![create_byte_range_result(
5341 file_path.to_str().unwrap(),
5342 1,
5343 1,
5344 6,
5345 11,
5346 "world", "rust",
5348 )];
5349
5350 std::fs::write(&file_path, "hello earth").unwrap();
5352
5353 let result = replace_in_file(&mut results);
5354 assert!(result.is_ok());
5355 assert!(matches!(
5356 &results[0].replace_result,
5357 Some(ReplaceResult::Error(msg)) if msg.contains("File changed since search")
5358 ));
5359 assert_file_content(&file_path, "hello earth");
5361 }
5362
5363 #[test]
5364 fn test_byte_mode_file_fully_truncated_single_replacement() {
5365 let temp_dir = TempDir::new().unwrap();
5366 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5367
5368 let mut results = vec![create_byte_range_result(
5370 file_path.to_str().unwrap(),
5371 1,
5372 1,
5373 6,
5374 11,
5375 "world",
5376 "rust",
5377 )];
5378
5379 std::fs::write(&file_path, "hello").unwrap();
5381
5382 let result = replace_in_file(&mut results);
5383 assert!(result.is_ok());
5384 assert!(results[0].replace_result.is_none());
5386 assert_file_content(&file_path, "hello");
5388 }
5389
5390 #[test]
5391 fn test_byte_mode_file_partially_truncated_single_replacement() {
5392 let temp_dir = TempDir::new().unwrap();
5393 let file_path = create_test_file(&temp_dir, "test.txt", "hello world hi world");
5394
5395 let mut results = vec![
5396 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 6, 11, "world", "rust"),
5397 create_byte_range_result(
5398 file_path.to_str().unwrap(),
5399 1,
5400 1,
5401 15,
5402 20,
5403 "world",
5404 "blah",
5405 ),
5406 ];
5407
5408 std::fs::write(&file_path, "hello world hi wo").unwrap();
5409
5410 let result = replace_in_file(&mut results);
5411 assert!(result.is_ok());
5412 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5413 assert!(results[1].replace_result.is_none());
5415 assert_file_content(&file_path, "hello rust hi wo");
5417 }
5418
5419 #[test]
5420 fn test_byte_mode_file_truncated_partial_match() {
5421 let temp_dir = TempDir::new().unwrap();
5422 let file_path = create_test_file(&temp_dir, "test.txt", "hello world test");
5423
5424 let mut results = vec![create_byte_range_result(
5426 file_path.to_str().unwrap(),
5427 1,
5428 1,
5429 6,
5430 11,
5431 "world",
5432 "rust",
5433 )];
5434
5435 std::fs::write(&file_path, "hello wo").unwrap();
5437
5438 let result = replace_in_file(&mut results);
5439 assert!(result.is_ok());
5440 assert!(results[0].replace_result.is_none());
5442 assert_file_content(&file_path, "hello wo");
5444 }
5445
5446 #[test]
5447 fn test_byte_mode_file_truncated_multiple_replacements() {
5448 let temp_dir = TempDir::new().unwrap();
5449 let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz qux");
5451
5452 let mut results = vec![
5453 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
5454 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
5455 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 12, 15, "qux", "DDD"),
5456 ];
5457
5458 std::fs::write(&file_path, "foo bar b").unwrap();
5461
5462 let result = replace_in_file(&mut results);
5463 assert!(result.is_ok());
5464 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5466 assert!(results[1].replace_result.is_none());
5468 assert!(results[2].replace_result.is_none());
5469 assert_file_content(&file_path, "AAA bar b");
5471 }
5472
5473 #[test]
5474 fn test_byte_mode_first_replacement_succeeds_second_content_changed() {
5475 let temp_dir = TempDir::new().unwrap();
5476 let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz");
5477
5478 let mut results = vec![
5479 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
5480 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
5481 ];
5482
5483 std::fs::write(&file_path, "foo bar qux").unwrap();
5485
5486 let result = replace_in_file(&mut results);
5487 assert!(result.is_ok());
5488 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5490 assert!(matches!(
5492 &results[1].replace_result,
5493 Some(ReplaceResult::Error(msg)) if msg.contains("File changed since search")
5494 ));
5495 assert_file_content(&file_path, "AAA bar qux");
5497 }
5498
5499 #[test]
5500 fn test_byte_mode_replacement_with_different_length() {
5501 let temp_dir = TempDir::new().unwrap();
5502 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5503
5504 let mut results = vec![create_byte_range_result(
5506 file_path.to_str().unwrap(),
5507 1,
5508 1,
5509 6,
5510 11,
5511 "world",
5512 "everyone",
5513 )];
5514
5515 let result = replace_in_file(&mut results);
5516 assert!(result.is_ok());
5517 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5518 assert_file_content(&file_path, "hello everyone");
5519 }
5520
5521 #[test]
5522 fn test_byte_mode_multiline_replacement() {
5523 let temp_dir = TempDir::new().unwrap();
5524 let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
5526
5527 let mut results = vec![create_byte_range_result(
5529 file_path.to_str().unwrap(),
5530 2,
5531 2,
5532 6,
5533 12,
5534 "line2\n",
5535 "REPLACED\n",
5536 )];
5537
5538 let result = replace_in_file(&mut results);
5539 assert!(result.is_ok());
5540 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5541 assert_file_content(&file_path, "line1\nREPLACED\nline3\n");
5542 }
5543
5544 #[test]
5545 fn test_byte_mode_spanning_multiple_lines() {
5546 let temp_dir = TempDir::new().unwrap();
5547 let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
5549
5550 let mut results = vec![create_byte_range_result(
5552 file_path.to_str().unwrap(),
5553 1,
5554 2,
5555 0,
5556 12,
5557 "line1\nline2\n",
5558 "REPLACED\n",
5559 )];
5560
5561 let result = replace_in_file(&mut results);
5562 assert!(result.is_ok());
5563 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5564 assert_file_content(&file_path, "REPLACED\nline3\n");
5565 }
5566
5567 #[test]
5568 fn test_byte_mode_empty_file() {
5569 let temp_dir = TempDir::new().unwrap();
5570 let file_path = create_test_file(&temp_dir, "test.txt", "hello");
5571
5572 let mut results = vec![create_byte_range_result(
5574 file_path.to_str().unwrap(),
5575 1,
5576 1,
5577 0,
5578 5,
5579 "hello",
5580 "world",
5581 )];
5582
5583 std::fs::write(&file_path, "").unwrap();
5585
5586 let result = replace_in_file(&mut results);
5587 assert!(result.is_ok());
5588 assert!(results[0].replace_result.is_none());
5590 assert_file_content(&file_path, "");
5592 }
5593
5594 #[test]
5595 fn test_byte_mode_preserves_trailing_content() {
5596 let temp_dir = TempDir::new().unwrap();
5597 let file_path = create_test_file(&temp_dir, "test.txt", "hello world and more");
5598
5599 let mut results = vec![create_byte_range_result(
5601 file_path.to_str().unwrap(),
5602 1,
5603 1,
5604 6,
5605 11,
5606 "world",
5607 "rust",
5608 )];
5609
5610 let result = replace_in_file(&mut results);
5611 assert!(result.is_ok());
5612 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5613 assert_file_content(&file_path, "hello rust and more");
5614 }
5615
5616 #[test]
5617 fn test_byte_mode_empty_replacement() {
5618 let temp_dir = TempDir::new().unwrap();
5619 let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
5620
5621 let mut results = vec![create_byte_range_result(
5623 file_path.to_str().unwrap(),
5624 1,
5625 1,
5626 6,
5627 11,
5628 "world",
5629 "",
5630 )];
5631
5632 let result = replace_in_file(&mut results);
5633 assert!(result.is_ok());
5634 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5635 assert_file_content(&file_path, "hello ");
5636 }
5637
5638 #[test]
5639 fn test_byte_mode_unicode_content() {
5640 let temp_dir = TempDir::new().unwrap();
5641 let file_path = create_test_file(&temp_dir, "test.txt", "hello 世界 test");
5644
5645 let mut results = vec![create_byte_range_result(
5647 file_path.to_str().unwrap(),
5648 1,
5649 1,
5650 6,
5651 12,
5652 "世界",
5653 "world",
5654 )];
5655
5656 let result = replace_in_file(&mut results);
5657 assert!(result.is_ok());
5658 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5659 assert_file_content(&file_path, "hello world test");
5660 }
5661
5662 #[test]
5663 fn test_byte_mode_unicode_replacement() {
5664 let temp_dir = TempDir::new().unwrap();
5665 let file_path = create_test_file(&temp_dir, "test.txt", "hello world test");
5666
5667 let mut results = vec![create_byte_range_result(
5669 file_path.to_str().unwrap(),
5670 1,
5671 1,
5672 6,
5673 11,
5674 "world",
5675 "世界",
5676 )];
5677
5678 let result = replace_in_file(&mut results);
5679 assert!(result.is_ok());
5680 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5681 assert_file_content(&file_path, "hello 世界 test");
5682 }
5683
5684 #[test]
5685 fn test_byte_mode_multiple_unicode_replacements() {
5686 let temp_dir = TempDir::new().unwrap();
5687 let file_path = create_test_file(&temp_dir, "test.txt", "aaa bbb ccc");
5689
5690 let mut results = vec![
5691 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "aaa", "日"),
5692 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 4, 7, "bbb", "本"),
5693 create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "ccc", "語"),
5694 ];
5695
5696 let result = replace_in_file(&mut results);
5697 assert!(result.is_ok());
5698 assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5699 assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
5700 assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
5701 assert_file_content(&file_path, "日 本 語");
5702 }
5703 }
5704}