Skip to main content

scooter_core/
replace.rs

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    // For advanced regex lookarounds, the replacement must be computed with full-file context.
179    // Without the surrounding text, lookbehind/lookahead checks fail.
180    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    // Read the full file once when context is required; all results are for the same file.
186    // Route through the FileContentProvider so the TUI can reuse its file cache.
187    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); // Ignore error if receiver is dropped
331            },
332        );
333
334        let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); // Slightly random duration so that time taken isn't a round number
335
336        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        // Ignore error: we may have gone back to the previous screen
353        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
370/// Sorts by byte offset, and detects and marks conflicting byte-range replacements.
371/// Two replacements conflict if their byte ranges overlap.
372/// Only valid for `ByteRange` content - panics if `Lines` content is encountered.
373fn mark_conflicting_replacements(results: &mut [SearchResultWithReplacement]) {
374    // Sort by byte_start
375    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
408/// NOTE: this should only be called with search results from the same file
409// TODO: enforce the above via types
410pub 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
426/// Line-mode replacement: Replace ALL occurrences on the line
427fn 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
480/// Byte-mode replacement: Replace only the specific byte range for each match
481fn 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            // Copy bytes from current_pos to byte_start
529            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            // Read the expected match bytes
538            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                // Hit EOF before reading full match - write what we got and break
546                // Leave replace_result as None, `calculate_statistics` will mark as error
547                writer.write_all(&actual_bytes)?;
548                break;
549            }
550
551            // Full read - check if content matches
552            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        // Copy remaining bytes
564        std::io::copy(&mut input, &mut writer)?;
565        writer.flush()?;
566    }
567
568    temp_output_file.persist(file_path)?;
569    Ok(())
570}
571
572/// Performs search and replace operations in a file
573///
574/// When multiline is enabled: reads the entire file into memory and performs replacements spanning multiple lines.
575/// When multiline is disabled: reads line-by-line and performs replacements within lines.
576///
577/// # Arguments
578///
579/// * `file_path` - Path to the file to process
580/// * `search` - The search pattern (fixed string, regex, or advanced regex)
581/// * `replace` - The replacement string
582/// * `multiline` - Whether to enable multiline replacement (whole-text matching)
583///
584/// # Returns
585///
586/// * `Ok(true)` if replacements were made in the file
587/// * `Ok(false)` if no replacements were made (no matches found)
588/// * `Err` if any errors occurred during the operation
589pub 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
683/// Calculate replacement text for a line containing matches.
684///
685/// This is used in line-mode search where we replace ALL occurrences of the pattern
686/// on the line(s). Returns the full line content with all matches replaced.
687///
688/// For both fixed and pattern searches, this uses `replace_all` semantics.
689///
690/// # Arguments
691///
692/// * `line` - The string to search within
693/// * `search` - The search pattern (fixed string, regex, or advanced regex)
694/// * `replace` - The replacement string
695///
696/// # Returns
697///
698/// * `Some(String)` containing the string with ALL replacements if matches were found
699/// * `None` if no matches were found
700pub 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
717/// Calculate replacement text for a specific matched substring.
718///
719/// This is used in byte-mode multiline search where we track individual matches
720/// and need to replace only the specific occurrence. Returns the replacement text
721/// that should substitute the matched portion.
722///
723/// For fixed searches, this simply returns the replacement string.
724/// For pattern searches, this applies the regex replacement to the matched text.
725///
726/// # Arguments
727///
728/// * `matched_text` - The specific text that was matched
729/// * `search` - The search pattern (fixed string, regex, or advanced regex)
730/// * `replace` - The replacement string
731///
732/// # Returns
733///
734/// * `String` containing the replacement text for this specific match
735pub 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
743/// Calculate replacement text for a specific match within a larger haystack.
744///
745/// This is used for byte-range matches where advanced regex lookarounds require
746/// the surrounding context to compute the replacement correctly.
747///
748/// Returns `None` if the match cannot be found at the given byte range.
749pub 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
790/// Interpret escape sequences in replacement text.
791///
792/// Converts:
793/// - `\n` → newline
794/// - `\r` → carriage return
795/// - `\t` → tab
796/// - `\\` → literal backslash
797///
798/// Other escape sequences are left as-is (e.g., `\x` remains `\x`).
799pub 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                    // Leave unrecognized escape sequences as-is
824                    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        // Replacement should NOT include line ending - replace_line_mode appends it
1215        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            ), // This should be treated as an error
1591            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        // Simulate what spawn_replace_included does: move preview_error into replace_result
1631        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    // Tests for replace_in_file
1681    #[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        // Create search results
1691        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        // Perform replacement
1713        let result = replace_in_file(&mut results);
1714        assert!(result.is_ok());
1715
1716        // Verify replacements were marked as successful
1717        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        // Verify file content
1722        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        // Create search results
1735        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        // Perform replacement
1757        let result = replace_in_file(&mut results);
1758        assert!(result.is_ok());
1759
1760        // Verify replacements were marked as successful
1761        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        // Verify file content
1766        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        // Create search results
1780        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        // Perform replacement
1802        let result = replace_in_file(&mut results);
1803        assert!(result.is_ok());
1804
1805        // Verify replacements were marked as successful
1806        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        // Verify file content
1811        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        // Create search results
1828        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        // Perform replacement
1850        let result = replace_in_file(&mut results);
1851        assert!(result.is_ok());
1852
1853        // Verify replacements were marked as successful
1854        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        // Verify file content
1859        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        // Create search result with mismatching line
1872        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        // Perform replacement
1883        let result = replace_in_file(&mut results);
1884        assert!(result.is_ok());
1885
1886        // Verify replacement was marked as error
1887        assert_eq!(
1888            results[0].replace_result,
1889            Some(ReplaceResult::Error(
1890                "File changed since last search".to_owned()
1891            ))
1892        );
1893
1894        // Verify file content is unchanged
1895        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    // Tests for replace_in_memory
1932    #[test]
1933    fn test_replace_in_memory() {
1934        let temp_dir = TempDir::new().unwrap();
1935
1936        // Test with fixed string
1937        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()); // Should return true for modifications
1946
1947        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        // Test with regex pattern
1953        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(&regex_path, &regex_search(r"\d{3}"), "XXX");
1960        assert!(result.is_ok());
1961        assert!(result.unwrap());
1962
1963        assert_file_content(&regex_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()); // Should return false for no modifications
1978
1979        // Verify file content unchanged
1980        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        // Verify file still empty
1993        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    // Tests for replace_chunked
2007    #[test]
2008    fn test_replace_chunked() {
2009        let temp_dir = TempDir::new().unwrap();
2010
2011        // Test with fixed string
2012        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()); // Check that replacement happened
2022
2023        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        // Test with regex pattern
2029        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(&regex_path, &regex_search(r"\d{3}"), "XXX");
2036        assert!(result.is_ok());
2037        assert!(result.unwrap());
2038
2039        assert_file_content(
2040            &regex_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        // Verify file content unchanged
2059        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        // Verify file still empty
2072        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    // Tests for replace_all_in_file
2086    #[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            // Positive lookbehind and lookahead
2245            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            // Write some binary data (null bytes and other control characters)
2331            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            // Write a large file with search targets scattered throughout
2351            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); // Lines 0, 100, 200, ..., 900
2369            assert_eq!(results[0].search_result.start_line_number(), 1); // 1-indexed
2370            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            // At start of string
3584            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            // At end of string
3600            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            // With punctuation
3616            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        /// Helper to create a single-line `ByteRange` result for testing.
3944        /// `line_content` is the full line content (without newline).
3945        /// `match_start` and `match_end` are byte positions within the line content.
3946        /// `byte_start` is the absolute byte offset in the file.
3947        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        /// Helper to create a `ByteRange` `SearchResult` for testing multiline replacement.
3983        /// `byte_start` and `byte_end` define the byte range to replace (exclusive end).
3984        /// Lines are constructed from the `content`.
3985        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            // Parse `content` into lines
3995            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                    // Check if \n comes before \r\n (meaning standalone \n)
4003                    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                    // No newline - this is the last partial line
4018                    (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            // Validate that the computed line count matches the expected end_line
4033            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            // For multiline matches, we assume match starts at beginning of first line (0)
4044            // and ends at the end of the last line's content (excluding line ending)
4045            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        /// Helper to create a `ByteRange` `SearchResult` from line content.
4070        /// Computes byte offsets by reading the file and finding the line positions.
4071        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            // Skip to start_line and track byte position
4086            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; // +1 for newline
4092                current_line += 1;
4093            }
4094
4095            // Build expected content from lines
4096            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            // "line 1\nline 2\nline 3\nline 4\nline 5\n"
4122            // bytes: 0-6=line1, 7-13=line2, 14-20=line3, 21-27=line4, 28-34=line5
4123            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            // Replace lines 2-4: "line 2\nline 3\nline 4\n" = bytes 7-28 (exclusive end)
4130            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            // "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n"
4151            // bytes: 0-6=line1, 7-13=line2, 14-20=line3, 21-27=line4, 28-34=line5, 35-41=line6, 42-48=line7
4152            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                // Replace lines 1-2: "line 1\nline 2\n" = bytes 0-14 (exclusive end)
4160                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                // Replace lines 5-7: "line 5\nline 6\nline 7\n" = bytes 28-49 (exclusive end)
4170                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            // "line 1\nline 2\nline 3\nline 4\nline 5\n"
4193            // bytes: 0-6=line1, 7-13=line2, 14-20=line3, 21-27=line4, 28-34=line5
4194            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            // First replacement: lines 2-4 = bytes 7-28 (exclusive end)
4201            // Second replacement: lines 3-5 = bytes 14-35 (exclusive end, overlaps with first)
4202            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            // First succeeds, second conflicts
4227            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            // Only first replacement applied
4234            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            // File: "line 1\nline 2\n...line 15\n"
4241            // Byte positions:
4242            // lines 1-9: 7 bytes each (bytes 0-62)
4243            // lines 10-15: 8 bytes each
4244            // line 9: 56-62, line 10: 63-70, line 11: 71-78, line 12: 79-86, line 13: 87-94
4245            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                // First: lines 9-11 = bytes 56-79 (exclusive end)
4254                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                // Second: lines 10-13 = bytes 63-95 (exclusive end, overlaps with first)
4264                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                // Third: line 12 = bytes 79-87 (exclusive end, no overlap with first after first succeeds)
4274                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            // First succeeds (9-11)
4289            assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4290            // Second conflicts (10-13 overlaps with 9-11)
4291            assert!(matches!(
4292                results[1].replace_result,
4293                Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
4294            ));
4295            // Third succeeds (12-12, no overlap with first which ends at byte 78)
4296            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            // Test that non-overlapping byte offsets on same line don't conflict
4305            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            // All three should succeed (no byte overlap)
4319            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            // Verify file content: all three replaced
4324            assert_file_content(&file_path, "XXX YYY ZZZ\n");
4325        }
4326
4327        #[test]
4328        fn test_conflict_detection_byte_offsets_with_overlap() {
4329            // Test that overlapping byte offsets are detected as conflicts
4330            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            // First should succeed
4343            assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4344            // Second should be marked as conflict (overlaps with first at byte 2)
4345            assert_eq!(
4346                results[1].replace_result,
4347                Some(ReplaceResult::Error(
4348                    "Conflicts with previous replacement".to_owned()
4349                ))
4350            );
4351            // File should only have first replacement
4352            assert_file_content(&file_path, "XXXdef\n");
4353        }
4354
4355        #[test]
4356        fn test_conflict_detection_byte_offsets_adjacent() {
4357            // Test that adjacent (touching) byte ranges conflict
4358            // Since byte offsets are inclusive, "abc" ends at byte 2 and "cdef" starts at byte 2,
4359            // so they overlap at byte 2 (the 'c')
4360            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            // First should succeed
4373            assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
4374            // Second should be marked as conflict (start_byte <= last_end_byte: 2 <= 2)
4375            assert_eq!(
4376                results[1].replace_result,
4377                Some(ReplaceResult::Error(
4378                    "Conflicts with previous replacement".to_owned()
4379                ))
4380            );
4381            // File should only have first replacement
4382            assert_file_content(&file_path, "XXXdef\n");
4383        }
4384
4385        #[test]
4386        fn test_conflict_detection_line_level_adjacent() {
4387            // Test that adjacent lines (no overlap) don't conflict
4388            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            // Both should succeed (no overlap: line 2 > line 1)
4422            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", // No newline - replacement should not have trailing newline
4582            )];
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            // No newline after the replacement
4616            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            // Search result expects "line 2" but file has "CHANGED"
4675            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            // File should remain unchanged
4694            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            // Expects 4 lines but file only has 2
4703            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            // replace_result is None because we hit EOF - calculate_statistics will mark as error
4718            assert!(results[0].replace_result.is_none());
4719
4720            // File should remain unchanged (we read partial content and wrote it back)
4721            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            // Provide replacements in reverse order (5, then 1-2)
4777            // Implementation should sort them and process correctly
4778            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            // Test that multiple matches on the same line with byte offsets don't conflict
4804            // "foo\nbar baz bar qux\nbar\nbux\n"
4805            //  0123 456789012345678 901234567
4806            //       ^     ^         ^
4807            //       4-6   12-14     20-22
4808            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            // Use search_multiline to get real byte offsets
4813            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            // Should find 3 matches: 2 on line 2, 1 on line 3
4818            assert_eq!(search_results.len(), 3);
4819
4820            // First match: "bar" at bytes 4-7 on line 2 (exclusive end)
4821            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            // Second match: "bar" at bytes 12-15 on line 2 (same line!)
4827            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            // Third match: "bar" at bytes 20-23 on line 3
4833            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            // Convert to replacements
4839            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            // Attempt to replace - this will call mark_conflicting_replacements internally
4850            let result = replace_in_file(&mut results);
4851            assert!(result.is_ok());
4852
4853            // All three should succeed (no conflicts due to non-overlapping byte offsets)
4854            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            // File should have all three replacements
4859            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            // Test that multiple matches on the same line are all replaced correctly
4868            // when they have non-overlapping byte offsets
4869            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            // Use search_multiline to get real byte offsets
4874            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            // Should find 3 matches: 2 on line 2, 1 on line 3
4879            assert_eq!(search_results.len(), 3);
4880
4881            // Verify byte offsets are correct
4882            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            // Convert to replacements
4887            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            // All three should succeed (no conflicts due to non-overlapping byte offsets)
4901            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            // File should have all three replacements
4906            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        /// Create a `ByteRange` result for conflict detection testing.
4918        /// Since `mark_conflicting_replacements` only handles `ByteRange`, this always creates `ByteRange`.
4919        /// Uses fake line content where match spans from start of first line to end of last line.
4920        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            // Sequential non-overlapping byte ranges: 0-9, 10-19, 20-29
4959            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            // Overlapping byte ranges: 0-10 and 5-15 overlap
4976            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            // Two multiline results with overlapping byte ranges
4996            // First: bytes 0-17 (lines 1-3)
4997            // Second: bytes 6-23 (lines 2-4) - overlaps with first
4998            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            // Adjacent non-overlapping multiline byte ranges
5018            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            // Byte offsets are exclusive, so end=5 and start=5 are adjacent, not overlapping
5049            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            // Adjacent ranges should not conflict
5058            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            // Results provided out of order
5100            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            // After sorting by byte_start: 0-3, 5-8, 10-15
5109            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            // With exclusive end: 0-10 and 5-15 overlap, but 0-10 and 10-20 are adjacent
5123            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            // First succeeds
5133            assert_eq!(results[0].replace_result, None);
5134            // Second conflicts (5-15 overlaps with 0-10 at bytes 5-9)
5135            assert_eq!(
5136                results[1].replace_result,
5137                Some(ReplaceResult::Error(
5138                    "Conflicts with previous replacement".to_owned()
5139                ))
5140            );
5141            // Third does NOT conflict (10-20 starts exactly where 0-10 ends, they're adjacent)
5142            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            // Replace "world" (bytes 6-11, exclusive end) with "rust"
5220            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            // "foo bar baz qux" - bytes: foo=0-3, bar=4-7, baz=8-11, qux=12-15 (exclusive end)
5240            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            // Replace "hello" (bytes 0-5, exclusive end) with "hi"
5262            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, // zero-length match
5297                    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            // Replace "world" (bytes 6-11, exclusive end) with "everyone"
5317            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            // Create result expecting "world" but file has different content at that position
5339            // We'll manually overwrite the file to simulate a change
5340            let mut results = vec![create_byte_range_result(
5341                file_path.to_str().unwrap(),
5342                1,
5343                1,
5344                6,
5345                11,
5346                "world", // Expected
5347                "rust",
5348            )];
5349
5350            // Change the file content before replacement
5351            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            // File should have original (changed) content preserved
5360            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            // Create result expecting bytes 6-11 ("world", exclusive end)
5369            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            // Truncate file to only "hello" (5 bytes + null at position 5 would be beyond)
5380            std::fs::write(&file_path, "hello").unwrap();
5381
5382            let result = replace_in_file(&mut results);
5383            assert!(result.is_ok());
5384            // replace_result is None because we hit EOF - calculate_statistics will mark as error
5385            assert!(results[0].replace_result.is_none());
5386            // File should remain as truncated (we write what we could read)
5387            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            // Second result is None because we hit EOF - calculate_statistics will mark as error
5414            assert!(results[1].replace_result.is_none());
5415            // File should remain as truncated (we write what we could read)
5416            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            // Create result expecting bytes 6-11 ("world", exclusive end)
5425            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            // Truncate file to "hello wo" (8 bytes) - partial match
5436            std::fs::write(&file_path, "hello wo").unwrap();
5437
5438            let result = replace_in_file(&mut results);
5439            assert!(result.is_ok());
5440            // replace_result is None because we hit EOF - calculate_statistics will mark as error
5441            assert!(results[0].replace_result.is_none());
5442            // File should have "hello" + whatever was read ("wo")
5443            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            // "foo bar baz qux" - bytes: foo=0-3, bar=4-7, baz=8-11, qux=12-15 (exclusive end)
5450            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            // Truncate file after "foo bar " (8 bytes) - first replacement succeeds,
5459            // second is partially there, third is gone
5460            std::fs::write(&file_path, "foo bar b").unwrap();
5461
5462            let result = replace_in_file(&mut results);
5463            assert!(result.is_ok());
5464            // First replacement should succeed
5465            assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5466            // Second and third replacements are None (EOF hit during second, third never processed)
5467            assert!(results[1].replace_result.is_none());
5468            assert!(results[2].replace_result.is_none());
5469            // File should have first replacement + copied bytes + partial read bytes
5470            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            // Change content at second match position
5484            std::fs::write(&file_path, "foo bar qux").unwrap();
5485
5486            let result = replace_in_file(&mut results);
5487            assert!(result.is_ok());
5488            // First replacement should succeed
5489            assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
5490            // Second replacement should fail (content mismatch)
5491            assert!(matches!(
5492                &results[1].replace_result,
5493                Some(ReplaceResult::Error(msg)) if msg.contains("File changed since search")
5494            ));
5495            // File should have first replacement + original (changed) content preserved
5496            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            // Replace "world" (5 chars) with "everyone" (8 chars), bytes 6-11 exclusive
5505            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            // "line1\nline2\nline3\n" - line1\n = 0-6, line2\n = 6-12, line3\n = 12-18 (exclusive end)
5525            let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
5526
5527            // Replace "line2\n" (bytes 6-12, exclusive end) with "REPLACED\n"
5528            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            // "line1\nline2\nline3\n"
5548            let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
5549
5550            // Replace bytes spanning lines 1-2 (bytes 0-12, exclusive end = "line1\nline2\n")
5551            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            // Create result expecting bytes 0-5 ("hello", exclusive end)
5573            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            // Empty the file
5584            std::fs::write(&file_path, "").unwrap();
5585
5586            let result = replace_in_file(&mut results);
5587            assert!(result.is_ok());
5588            // replace_result is None because we hit EOF - calculate_statistics will mark as error
5589            assert!(results[0].replace_result.is_none());
5590            // File should remain empty
5591            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            // Replace only "world" (bytes 6-11, exclusive end), should preserve " and more"
5600            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            // Replace "world" with empty string, bytes 6-11 (exclusive end)
5622            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            // "hello 世界 test" - 世界 starts at byte 6, each char is 3 bytes
5642            // h=0, e=1, l=2, l=3, o=4, space=5, 世=6-8, 界=9-11, space=12, t=13, e=14, s=15, t=16
5643            let file_path = create_test_file(&temp_dir, "test.txt", "hello 世界 test");
5644
5645            // Replace "世界" (bytes 6-12, exclusive end) with "world"
5646            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            // Replace "world" (bytes 6-11, exclusive end) with "世界"
5668            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            // "aaa bbb ccc" - a=0-3, space=3, b=4-7, space=7, c=8-11 (exclusive end)
5688            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}