use anyhow::Context;
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
use std::{
collections::HashMap,
fs::{self, File},
io::{BufReader, BufWriter, Write},
num::NonZero,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
};
use tempfile::NamedTempFile;
use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use crate::{
app::{BackgroundProcessingEvent, Event, EventHandlingResult},
commands::CommandResults,
file_content::FileContentProvider,
line_reader::BufReadExt,
replace,
search::{
self, FileSearcher, MatchContent, MatchMode, SearchResult, SearchResultWithReplacement,
SearchType,
},
};
#[cfg(unix)]
fn create_temp_file_in_with_permissions(
parent_dir: &Path,
original_file_path: &Path,
) -> anyhow::Result<NamedTempFile> {
let original_permissions = fs::metadata(original_file_path)?.permissions();
let temp_file = NamedTempFile::new_in(parent_dir)?;
fs::set_permissions(temp_file.path(), original_permissions)?;
Ok(temp_file)
}
#[cfg(not(unix))]
fn create_temp_file_in_with_permissions(
parent_dir: &Path,
_original_file_path: &Path,
) -> anyhow::Result<NamedTempFile> {
Ok(NamedTempFile::new_in(parent_dir)?)
}
pub fn split_results(
results: Vec<SearchResultWithReplacement>,
) -> (
Vec<SearchResultWithReplacement>,
Vec<SearchResultWithReplacement>,
usize,
) {
let (included, excluded): (Vec<_>, Vec<_>) = results
.into_iter()
.partition(|res| res.search_result.included);
let num_ignored = excluded.len();
let (replaceable, preview_errored): (Vec<_>, Vec<_>) = included
.into_iter()
.partition(|res| res.preview_error.is_none());
(replaceable, preview_errored, num_ignored)
}
fn group_results(
included: Vec<SearchResultWithReplacement>,
) -> HashMap<Option<PathBuf>, Vec<SearchResultWithReplacement>> {
let mut path_groups = HashMap::<Option<PathBuf>, Vec<SearchResultWithReplacement>>::new();
for res in included {
path_groups
.entry(res.search_result.path.clone())
.or_default()
.push(res);
}
path_groups
}
pub fn spawn_replace_included<T: Fn(SearchResultWithReplacement) + Send + Sync + 'static>(
search_results: Vec<SearchResultWithReplacement>,
cancelled: Arc<AtomicBool>,
replacements_completed: Arc<AtomicUsize>,
validation_search_config: Option<FileSearcher>,
file_content_provider: Arc<dyn FileContentProvider>,
on_completion: T,
) -> usize {
let (included, preview_errored, num_ignored) = split_results(search_results);
thread::spawn(move || {
for mut result in preview_errored {
let error = result
.preview_error
.take()
.expect("preview_errored results must have preview_error set");
result.replace_result = Some(ReplaceResult::Error(error));
replacements_completed.fetch_add(1, Ordering::Relaxed);
on_completion(result);
}
let path_groups = group_results(included);
let num_threads = thread::available_parallelism()
.map(NonZero::get)
.unwrap_or(4)
.min(12);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(num_threads)
.build()
.unwrap();
pool.install(|| {
path_groups.into_par_iter().for_each(|(path, mut results)| {
if cancelled.load(Ordering::Relaxed) {
return;
}
if let Some(config) = &validation_search_config
&& let Err(e) = validate_search_result_correctness(
config,
&results,
file_content_provider.as_ref(),
)
{
for res in &mut results {
res.replace_result =
Some(ReplaceResult::Error(format!("Validation failed: {e}")));
}
for result in results {
on_completion(result);
}
return;
}
if let Err(file_err) = replace_in_file(&mut results) {
for res in &mut results {
res.replace_result = Some(ReplaceResult::Error(file_err.to_string()));
}
}
if let Some(path) = path.as_ref() {
file_content_provider.invalidate(path);
}
replacements_completed.fetch_add(results.len(), Ordering::Relaxed);
for result in results {
on_completion(result);
}
});
});
});
num_ignored
}
fn validate_search_result_correctness(
validation_search_config: &FileSearcher,
results: &[SearchResultWithReplacement],
file_content_provider: &dyn FileContentProvider,
) -> anyhow::Result<()> {
let Some(res) = results.first() else {
return Ok(());
};
let expected_path = res
.search_result
.path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Expected file path for validation"))?;
if !results
.iter()
.all(|r| r.search_result.path.as_ref() == Some(expected_path))
{
anyhow::bail!("Validation expects all results to share the same path");
}
let needs_context = validation_search_config.search().needs_haystack_context()
&& results
.iter()
.any(|r| matches!(r.search_result.content, MatchContent::ByteRange { .. }));
let haystack = if needs_context {
Some(read_validation_haystack(
expected_path,
file_content_provider,
)?)
} else {
None
};
for res in results {
let expected = match &res.search_result.content {
MatchContent::Line { .. } => replace_all_if_match(
res.search_result.content.matched_text(),
validation_search_config.search(),
validation_search_config.replace(),
),
MatchContent::ByteRange {
byte_start,
byte_end,
..
} => {
let replacement = if let Some(haystack) = haystack.as_deref() {
replacement_for_match_in_haystack(
validation_search_config.search(),
validation_search_config.replace(),
haystack,
*byte_start,
*byte_end,
)
.ok_or_else(|| anyhow::anyhow!("Expected match at byte range for validation"))?
} else {
replacement_for_match(
res.search_result.content.matched_text(),
validation_search_config.search(),
validation_search_config.replace(),
)
};
Some(replacement)
}
};
let actual = &res.replacement;
anyhow::ensure!(
expected.as_ref() == Some(actual),
"Expected replacement does not match actual"
);
}
Ok(())
}
fn read_validation_haystack(
path: &Path,
file_content_provider: &dyn FileContentProvider,
) -> anyhow::Result<Arc<String>> {
file_content_provider
.read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file for replacement validation: {e}"))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReplaceState {
pub num_successes: usize,
pub num_ignored: usize,
pub errors: Vec<SearchResultWithReplacement>,
pub replacement_errors_pos: usize,
}
impl ReplaceState {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn handle_command_results(&mut self, event: CommandResults) -> EventHandlingResult {
#[allow(clippy::match_same_arms)]
match event {
CommandResults::ScrollErrorsDown => {
self.scroll_replacement_errors_down();
EventHandlingResult::Rerender
}
CommandResults::ScrollErrorsUp => {
self.scroll_replacement_errors_up();
EventHandlingResult::Rerender
}
CommandResults::Quit => EventHandlingResult::Exit(None),
}
}
pub fn scroll_replacement_errors_up(&mut self) {
if self.replacement_errors_pos == 0 {
self.replacement_errors_pos = self.errors.len();
}
self.replacement_errors_pos = self.replacement_errors_pos.saturating_sub(1);
}
pub fn scroll_replacement_errors_down(&mut self) {
if self.replacement_errors_pos >= self.errors.len().saturating_sub(1) {
self.replacement_errors_pos = 0;
} else {
self.replacement_errors_pos += 1;
}
}
}
#[derive(Debug)]
pub struct PerformingReplacementState {
pub processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
pub cancelled: Arc<AtomicBool>,
pub replacement_started: Instant,
pub num_replacements_completed: Arc<AtomicUsize>,
pub total_replacements: usize,
}
impl PerformingReplacementState {
pub fn new(
processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
cancelled: Arc<AtomicBool>,
num_replacements_completed: Arc<AtomicUsize>,
total_replacements: usize,
) -> Self {
Self {
processing_receiver,
cancelled,
replacement_started: Instant::now(),
num_replacements_completed,
total_replacements,
}
}
}
pub fn perform_replacement(
search_results: Vec<SearchResultWithReplacement>,
background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
cancelled: Arc<AtomicBool>,
replacements_completed: Arc<AtomicUsize>,
event_sender: UnboundedSender<Event>,
validation_search_config: Option<FileSearcher>,
file_content_provider: Arc<dyn FileContentProvider>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let num_ignored = replace::spawn_replace_included(
search_results,
cancelled,
replacements_completed,
validation_search_config,
file_content_provider,
move |result| {
let _ = tx.send(result); },
);
let mut rerender_interval = tokio::time::interval(Duration::from_millis(92));
let mut replacement_results = Vec::new();
loop {
tokio::select! {
res = rx.recv() => match res {
Some(res) => replacement_results.push(res),
None => break,
},
_ = rerender_interval.tick() => {
let _ = event_sender.send(Event::Rerender);
}
}
}
let _ = event_sender.send(Event::Rerender);
let stats = crate::replace::calculate_statistics(replacement_results);
let _ = background_processing_sender.send(BackgroundProcessingEvent::ReplacementCompleted(
ReplaceState {
num_successes: stats.num_successes,
num_ignored,
errors: stats.errors,
replacement_errors_pos: 0,
},
));
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReplaceResult {
Success,
Error(String),
}
fn mark_conflicting_replacements(results: &mut [SearchResultWithReplacement]) {
results.sort_by_key(|r| match &r.search_result.content {
MatchContent::ByteRange { byte_start, .. } => *byte_start,
MatchContent::Line { .. } => {
panic!(
"mark_conflicting_replacements called with Lines content - use only for byte-mode"
)
}
});
let mut last_end_byte: Option<usize> = None;
for result in results {
let MatchContent::ByteRange {
byte_start,
byte_end,
..
} = &result.search_result.content
else {
panic!(
"mark_conflicting_replacements called with Lines content - use only for byte-mode"
)
};
if last_end_byte.is_some_and(|last_end| *byte_start < last_end) {
result.replace_result = Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned(),
));
} else {
last_end_byte = Some(*byte_end);
}
}
}
pub fn replace_in_file(results: &mut [SearchResultWithReplacement]) -> anyhow::Result<()> {
let file_path = match results {
[r, ..] => r.search_result.path.clone(),
[] => return Ok(()),
};
assert!(results.iter().all(|r| r.search_result.path == file_path));
let file_path = file_path.expect("File path must be present when searching in files");
match search::match_mode_of_results(results).expect("replace_in_file called with empty results")
{
MatchMode::Line => replace_line_mode(&file_path, results),
MatchMode::ByteRange => replace_byte_mode(&file_path, results),
}
}
fn replace_line_mode(
file_path: &Path,
results: &mut [SearchResultWithReplacement],
) -> anyhow::Result<()> {
debug_assert!(
results.iter().all(|r| r.preview_error.is_none()),
"preview-errored results should not reach replace_line_mode"
);
let mut line_map: HashMap<usize, &mut SearchResultWithReplacement> = results
.iter_mut()
.map(|res| (res.search_result.start_line_number(), res))
.collect();
let parent_dir = file_path.parent().unwrap_or(Path::new("."));
let temp_output_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
{
let input = File::open(file_path)?;
let reader = BufReader::new(input);
let output = File::create(temp_output_file.path())?;
let mut writer = BufWriter::new(output);
for (idx, line_result) in reader.lines_with_endings().enumerate() {
let line_number = idx + 1;
let (mut line_bytes, line_ending) = line_result?;
if let Some(res) = line_map.get_mut(&line_number) {
let MatchContent::Line { content, .. } = &res.search_result.content else {
unreachable!("Line-mode must have Lines content")
};
if line_bytes == content.as_bytes() {
line_bytes = res.replacement.as_bytes().to_vec();
res.replace_result = Some(ReplaceResult::Success);
} else {
res.replace_result = Some(ReplaceResult::Error(
"File changed since last search".to_owned(),
));
}
}
line_bytes.extend(line_ending.as_bytes());
writer.write_all(&line_bytes)?;
}
writer.flush()?;
}
temp_output_file.persist(file_path)?;
Ok(())
}
fn replace_byte_mode(
file_path: &Path,
results: &mut [SearchResultWithReplacement],
) -> anyhow::Result<()> {
use std::io::Read;
debug_assert!(
results.iter().all(|r| r.preview_error.is_none()),
"preview-errored results should not reach replace_byte_mode"
);
mark_conflicting_replacements(results);
let mut to_replace: Vec<_> = results
.iter_mut()
.filter(|r| r.replace_result.is_none())
.collect();
if to_replace.is_empty() {
return Ok(());
}
to_replace.sort_by_key(|r| match &r.search_result.content {
MatchContent::ByteRange { byte_start, .. } => *byte_start,
MatchContent::Line { .. } => unreachable!(),
});
let parent_dir = file_path.parent().unwrap_or(Path::new("."));
let temp_output_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
{
let mut input = File::open(file_path)?;
let output = File::create(temp_output_file.path())?;
let mut writer = BufWriter::new(output);
let mut current_pos: usize = 0;
for result in to_replace {
let MatchContent::ByteRange {
byte_start,
byte_end,
content,
..
} = &result.search_result.content
else {
unreachable!()
};
if *byte_start > current_pos {
let bytes_to_copy = byte_start - current_pos;
std::io::copy(
&mut Read::by_ref(&mut input).take(bytes_to_copy as u64),
&mut writer,
)?;
}
let match_len = byte_end - byte_start;
let mut actual_bytes = Vec::with_capacity(match_len);
let bytes_read = Read::by_ref(&mut input)
.take(match_len as u64)
.read_to_end(&mut actual_bytes)?;
if bytes_read < match_len {
writer.write_all(&actual_bytes)?;
break;
}
if actual_bytes != content.as_bytes() {
result.replace_result =
Some(ReplaceResult::Error("File changed since search".to_owned()));
writer.write_all(&actual_bytes)?;
} else {
result.replace_result = Some(ReplaceResult::Success);
writer.write_all(result.replacement.as_bytes())?;
}
current_pos = *byte_end;
}
std::io::copy(&mut input, &mut writer)?;
writer.flush()?;
}
temp_output_file.persist(file_path)?;
Ok(())
}
pub fn replace_all_in_file(
file_path: &Path,
search: &SearchType,
replace: &str,
multiline: bool,
) -> anyhow::Result<bool> {
if multiline {
return replace_in_memory(file_path, search, replace);
}
replace_line_by_line(file_path, search, replace)
}
pub fn add_replacement(
search_result: SearchResult,
search: &SearchType,
replace: &str,
) -> Option<SearchResultWithReplacement> {
add_replacement_with_haystack(search_result, search, replace, None)
}
pub fn add_replacement_with_haystack(
search_result: SearchResult,
search: &SearchType,
replace: &str,
haystack: Option<&str>,
) -> Option<SearchResultWithReplacement> {
let replacement = match &search_result.content {
MatchContent::Line { .. } => {
replace_all_if_match(search_result.content.matched_text(), search, replace)?
}
MatchContent::ByteRange {
byte_start,
byte_end,
..
} => {
if let Some(haystack) = haystack {
replacement_for_match_in_haystack(search, replace, haystack, *byte_start, *byte_end)
.unwrap_or_else(|| {
replacement_for_match(search_result.content.matched_text(), search, replace)
})
} else {
replacement_for_match(search_result.content.matched_text(), search, replace)
}
}
};
Some(SearchResultWithReplacement {
search_result,
replacement,
replace_result: None,
preview_error: None,
})
}
fn replace_line_by_line(
file_path: &Path,
search: &SearchType,
replace: &str,
) -> anyhow::Result<bool> {
let search_results = search::search_file(file_path, search, false)?;
if !search_results.is_empty() {
let mut replacement_results = search_results
.into_iter()
.map(|r| {
add_replacement(r, search, replace).unwrap_or_else(|| {
panic!("Called add_replacement with non-matching search result")
})
})
.collect::<Vec<_>>();
replace_in_file(&mut replacement_results)?;
return Ok(true);
}
Ok(false)
}
fn replace_in_memory(file_path: &Path, search: &SearchType, replace: &str) -> anyhow::Result<bool> {
let content = fs::read_to_string(file_path).with_context(|| {
format!(
"Failed to read file as UTF-8 for in-memory replacement: {}",
file_path.display()
)
})?;
if let Some(new_content) = replace_all_if_match(&content, search, replace) {
let parent_dir = file_path.parent().unwrap_or(Path::new("."));
let mut temp_file = create_temp_file_in_with_permissions(parent_dir, file_path)?;
temp_file.write_all(new_content.as_bytes())?;
temp_file.persist(file_path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn replace_all_if_match(line: &str, search: &SearchType, replace: &str) -> Option<String> {
if line.is_empty() || search.is_empty() {
return None;
}
if search::contains_search(line, search) {
let replacement = match search {
SearchType::Fixed(fixed_str) => line.replace(fixed_str, replace),
SearchType::Pattern(pattern) => pattern.replace_all(line, replace).to_string(),
SearchType::PatternAdvanced(pattern) => pattern.replace_all(line, replace).to_string(),
};
Some(replacement)
} else {
None
}
}
pub fn replacement_for_match(matched_text: &str, search: &SearchType, replace: &str) -> String {
match search {
SearchType::Fixed(_) => replace.to_string(),
SearchType::Pattern(pattern) => pattern.replace(matched_text, replace).to_string(),
SearchType::PatternAdvanced(pattern) => pattern.replace(matched_text, replace).to_string(),
}
}
pub fn replacement_for_match_in_haystack(
search: &SearchType,
replace: &str,
haystack: &str,
byte_start: usize,
byte_end: usize,
) -> Option<String> {
let slice = haystack.get(byte_start..byte_end)?;
match search {
SearchType::Fixed(fixed_str) => {
if slice != fixed_str {
return None;
}
Some(replace.to_string())
}
SearchType::Pattern(pattern) => pattern.captures_iter(haystack).find_map(|caps| {
let mat = caps.get(0)?;
if mat.start() == byte_start && mat.end() == byte_end {
let mut out = String::new();
caps.expand(replace, &mut out);
Some(out)
} else {
None
}
}),
SearchType::PatternAdvanced(pattern) => {
pattern.captures_iter(haystack).flatten().find_map(|caps| {
let mat = caps.get(0)?;
if mat.start() == byte_start && mat.end() == byte_end {
let mut out = String::new();
caps.expand(replace, &mut out);
Some(out)
} else {
None
}
})
}
}
}
pub fn interpret_escapes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('n') => {
chars.next();
result.push('\n');
}
Some('r') => {
chars.next();
result.push('\r');
}
Some('t') => {
chars.next();
result.push('\t');
}
Some('\\') => {
chars.next();
result.push('\\');
}
_ => {
result.push('\\');
}
}
} else {
result.push(c);
}
}
result
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReplaceStats {
pub num_successes: usize,
pub errors: Vec<SearchResultWithReplacement>,
}
pub fn calculate_statistics<I>(results: I) -> ReplaceStats
where
I: IntoIterator<Item = SearchResultWithReplacement>,
{
let mut num_successes = 0;
let mut errors = vec![];
results.into_iter().for_each(|mut res| {
assert!(
res.search_result.included,
"Expected only included results, found {res:?}"
);
debug_assert!(
res.preview_error.is_none(),
"preview_error should have been moved to replace_result before reaching calculate_statistics: {res:?}"
);
match &res.replace_result {
Some(ReplaceResult::Success) => {
num_successes += 1;
}
None => {
res.replace_result = Some(ReplaceResult::Error(
"Failed to find search result in file".to_owned(),
));
errors.push(res);
}
Some(ReplaceResult::Error(_)) => {
errors.push(res);
}
}
});
ReplaceStats {
num_successes,
errors,
}
}
#[cfg(test)]
mod tests {
use std::{
io::Write,
path::{Path, PathBuf},
};
use regex::Regex;
use tempfile::{NamedTempFile, TempDir};
use crate::{
line_reader::LineEnding,
replace::{
ReplaceResult, add_replacement, replace_all_if_match, replace_all_in_file,
replace_in_file, replace_in_memory, replace_line_by_line,
},
search::{
MatchContent, SearchResult, SearchResultWithReplacement, SearchType, search_file,
},
};
use crate::{
app::EventHandlingResult,
commands::CommandResults,
replace::{self, ReplaceState},
};
use super::{interpret_escapes, replacement_for_match_in_haystack};
fn line_content(result: &SearchResult) -> (&str, LineEnding) {
match &result.content {
MatchContent::Line {
content,
line_ending,
..
} => (content, *line_ending),
MatchContent::ByteRange { .. } => panic!("Expected Lines content"),
}
}
fn byte_range_content(result: &SearchResult) -> &str {
match &result.content {
MatchContent::ByteRange { content, .. } => content,
MatchContent::Line { .. } => panic!("Expected ByteRange"),
}
}
fn byte_range_bytes(result: &SearchResult) -> (usize, usize) {
match &result.content {
MatchContent::ByteRange {
byte_start,
byte_end,
..
} => (*byte_start, *byte_end),
MatchContent::Line { .. } => panic!("Expected ByteRange"),
}
}
mod interpret_escapes_tests {
use super::*;
#[test]
fn test_newline() {
assert_eq!(interpret_escapes(r"\n"), "\n");
assert_eq!(interpret_escapes(r"foo\nbar"), "foo\nbar");
assert_eq!(interpret_escapes(r"\n\n"), "\n\n");
}
#[test]
fn test_tab() {
assert_eq!(interpret_escapes(r"\t"), "\t");
assert_eq!(interpret_escapes(r"foo\tbar"), "foo\tbar");
}
#[test]
fn test_carriage_return() {
assert_eq!(interpret_escapes(r"\r"), "\r");
assert_eq!(interpret_escapes(r"\r\n"), "\r\n");
}
#[test]
fn test_backslash() {
assert_eq!(interpret_escapes(r"\\"), "\\");
assert_eq!(interpret_escapes(r"\\n"), "\\n");
assert_eq!(interpret_escapes(r"foo\\bar"), "foo\\bar");
}
#[test]
fn test_unrecognized_escapes_left_as_is() {
assert_eq!(interpret_escapes(r"\x"), "\\x");
assert_eq!(interpret_escapes(r"\a"), "\\a");
assert_eq!(interpret_escapes(r"\u0041"), "\\u0041");
}
#[test]
fn test_trailing_backslash() {
assert_eq!(interpret_escapes(r"foo\"), "foo\\");
}
#[test]
fn test_no_escapes() {
assert_eq!(interpret_escapes("hello world"), "hello world");
assert_eq!(interpret_escapes(""), "");
}
#[test]
fn test_mixed() {
assert_eq!(
interpret_escapes(r"line1\nline2\ttab\\slash"),
"line1\nline2\ttab\\slash"
);
}
}
mod replacement_for_match_in_haystack_tests {
use super::*;
use fancy_regex::Regex as FancyRegex;
use regex::Regex;
#[test]
fn test_fixed_string_match() {
let haystack = "foo";
let search = SearchType::Fixed("foo".to_string());
let replacement =
replacement_for_match_in_haystack(&search, "bar", haystack, 0, 3).unwrap();
assert_eq!(replacement, "bar");
}
#[test]
fn test_fixed_string_mismatch() {
let haystack = "foo";
let search = SearchType::Fixed("foo".to_string());
assert!(replacement_for_match_in_haystack(&search, "bar", haystack, 0, 2).is_none());
}
#[test]
fn test_regex_match() {
let haystack = "abc123";
let search = SearchType::Pattern(Regex::new(r"\d+").unwrap());
let replacement =
replacement_for_match_in_haystack(&search, "NUM", haystack, 3, 6).unwrap();
assert_eq!(replacement, "NUM");
}
#[test]
fn test_regex_match_with_capture_groups() {
let haystack = "abc123def";
let search = SearchType::Pattern(Regex::new(r"(\d+)").unwrap());
let replacement =
replacement_for_match_in_haystack(&search, "NUM-$1", haystack, 3, 6).unwrap();
assert_eq!(replacement, "NUM-123");
}
#[test]
fn test_advanced_regex_lookaround_match() {
let haystack = "start\nmiddle\nend\n";
let search = SearchType::PatternAdvanced(
FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
);
let start = haystack.find("middle").unwrap();
let end = start + "middle".len();
let replacement =
replacement_for_match_in_haystack(&search, "REPLACED", haystack, start, end)
.unwrap();
assert_eq!(replacement, "REPLACED");
}
#[test]
fn test_advanced_regex_lookaround_with_capture_groups() {
let haystack = "foo-123-bar";
let search =
SearchType::PatternAdvanced(FancyRegex::new(r"(?<=foo-)(\d+)(?=-bar)").unwrap());
let start = haystack.find("123").unwrap();
let end = start + "123".len();
let replacement =
replacement_for_match_in_haystack(&search, "ID:$1", haystack, start, end).unwrap();
assert_eq!(replacement, "ID:123");
}
}
mod validate_search_result_correctness_tests {
use super::super::validate_search_result_correctness;
use crate::file_content::FileContentProvider;
use crate::line_reader::LineEnding;
use crate::search::{
ByteRangeParams, FileSearcher, Line, ParsedDirConfig, ParsedSearchConfig, SearchResult,
SearchResultWithReplacement, SearchType,
};
use fancy_regex::Regex as FancyRegex;
use ignore::overrides::Override;
use std::path::{Path, PathBuf};
use std::sync::Arc;
struct TestFileContentProvider {
contents: Arc<String>,
fail: bool,
}
impl FileContentProvider for TestFileContentProvider {
fn read_to_string(&self, _path: &Path) -> anyhow::Result<Arc<String>> {
if self.fail {
Err(anyhow::anyhow!("boom"))
} else {
Ok(Arc::clone(&self.contents))
}
}
}
fn build_searcher(search: SearchType, replace: &str) -> FileSearcher {
let search_config = ParsedSearchConfig {
search,
replace: replace.to_string(),
multiline: true,
};
let dir_config = ParsedDirConfig {
overrides: Override::empty(),
root_dir: PathBuf::from("."),
include_hidden: false,
};
FileSearcher::new(search_config, dir_config)
}
fn build_result(
path: &Path,
byte_start: usize,
byte_end: usize,
matched: &str,
replacement: &str,
) -> SearchResultWithReplacement {
let line = Line {
content: matched.to_string(),
line_ending: LineEnding::Lf,
};
let search_result = SearchResult::new_byte_range(ByteRangeParams {
path: Some(path.to_path_buf()),
lines: vec![(2, line)],
match_start_in_first_line: 0,
match_end_in_last_line: matched.len(),
byte_start,
byte_end,
content: matched.to_string(),
included: true,
});
SearchResultWithReplacement {
search_result,
replacement: replacement.to_string(),
replace_result: None,
preview_error: None,
}
}
#[test]
fn test_validate_search_result_correctness_advanced_regex_uses_haystack()
-> anyhow::Result<()> {
let haystack = "start\nmiddle\nend\n";
let search = SearchType::PatternAdvanced(
FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
);
let replace = "REPLACED";
let searcher = build_searcher(search, replace);
let start = haystack.find("middle").unwrap();
let end = start + "middle".len();
let path = PathBuf::from("file.txt");
let result = build_result(path.as_path(), start, end, "middle", replace);
let provider = TestFileContentProvider {
contents: Arc::new(haystack.to_string()),
fail: false,
};
validate_search_result_correctness(&searcher, &[result], &provider)?;
Ok(())
}
#[test]
fn test_validate_search_result_correctness_returns_error_on_read_failure() {
let haystack = "start\nmiddle\nend\n";
let search = SearchType::PatternAdvanced(
FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
);
let replace = "REPLACED";
let searcher = build_searcher(search, replace);
let start = haystack.find("middle").unwrap();
let end = start + "middle".len();
let path = PathBuf::from("file.txt");
let result = build_result(path.as_path(), start, end, "middle", replace);
let provider = TestFileContentProvider {
contents: Arc::new(haystack.to_string()),
fail: true,
};
let err = validate_search_result_correctness(&searcher, &[result], &provider);
assert!(err.is_err());
assert!(
err.unwrap_err()
.to_string()
.contains("Failed to read file for replacement validation")
);
}
#[test]
fn test_validate_search_result_correctness_returns_error_on_missing_match() {
let haystack = "start\nmiddle\nend\n";
let search = SearchType::PatternAdvanced(
FancyRegex::new(r"(?<=start\n)middle(?=\nend)").unwrap(),
);
let replace = "REPLACED";
let searcher = build_searcher(search, replace);
let start = haystack.find("middle").unwrap();
let end = start + "middle".len();
let path = PathBuf::from("file.txt");
let result = build_result(path.as_path(), start + 1, end + 1, "middle", replace);
let provider = TestFileContentProvider {
contents: Arc::new(haystack.to_string()),
fail: false,
};
let err = validate_search_result_correctness(&searcher, &[result], &provider);
assert!(err.is_err());
assert!(
err.unwrap_err()
.to_string()
.contains("Expected match at byte range for validation")
);
}
}
fn create_search_result_with_replacement(
path: &str,
line_number: usize,
line: &str,
line_ending: LineEnding,
replacement: &str,
included: bool,
replace_result: Option<ReplaceResult>,
) -> SearchResultWithReplacement {
SearchResultWithReplacement {
search_result: SearchResult::new_line(
Some(PathBuf::from(path)),
line_number,
line.to_string(),
line_ending,
included,
),
replacement: replacement.to_string(),
replace_result,
preview_error: None,
}
}
#[test]
fn test_split_results_all_included() {
let result1 = create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
None,
);
let result2 = create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
true,
None,
);
let result3 = create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
true,
None,
);
let search_results = vec![result1.clone(), result2.clone(), result3.clone()];
let (included, preview_errored, num_ignored) = replace::split_results(search_results);
assert_eq!(num_ignored, 0);
assert!(preview_errored.is_empty());
assert_eq!(included, vec![result1, result2, result3]);
}
#[test]
fn test_split_results_mixed() {
let result1 = create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
None,
);
let result2 = create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
false,
None,
);
let result3 = create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
true,
None,
);
let result4 = create_search_result_with_replacement(
"file4.txt",
4,
"line4",
LineEnding::Lf,
"repl4",
false,
None,
);
let search_results = vec![result1.clone(), result2, result3.clone(), result4];
let (included, preview_errored, num_ignored) = replace::split_results(search_results);
assert_eq!(num_ignored, 2);
assert!(preview_errored.is_empty());
assert_eq!(included, vec![result1, result3]);
assert!(included.iter().all(|r| r.search_result.included));
}
#[test]
fn test_split_results_separates_preview_errors() {
let mut normal = create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
None,
);
normal.preview_error = None;
let mut errored = create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"",
true,
None,
);
errored.preview_error = Some("file unreadable".to_string());
let excluded = create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
false,
None,
);
let search_results = vec![normal.clone(), errored.clone(), excluded];
let (included, preview_errored, num_ignored) = replace::split_results(search_results);
assert_eq!(num_ignored, 1);
assert_eq!(included, vec![normal]);
assert_eq!(preview_errored, vec![errored]);
}
#[test]
fn test_replace_state_scroll_replacement_errors_up() {
let mut state = ReplaceState {
num_successes: 5,
num_ignored: 2,
errors: vec![
create_search_result_with_replacement(
"file1.txt",
1,
"error1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Error("err1".to_string())),
),
create_search_result_with_replacement(
"file2.txt",
2,
"error2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Error("err2".to_string())),
),
create_search_result_with_replacement(
"file3.txt",
3,
"error3",
LineEnding::Lf,
"repl3",
true,
Some(ReplaceResult::Error("err3".to_string())),
),
],
replacement_errors_pos: 1,
};
state.scroll_replacement_errors_up();
assert_eq!(state.replacement_errors_pos, 0);
state.scroll_replacement_errors_up();
assert_eq!(state.replacement_errors_pos, 2);
state.scroll_replacement_errors_up();
assert_eq!(state.replacement_errors_pos, 1);
}
#[test]
fn test_replace_state_scroll_replacement_errors_down() {
let mut state = ReplaceState {
num_successes: 5,
num_ignored: 2,
errors: vec![
create_search_result_with_replacement(
"file1.txt",
1,
"error1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Error("err1".to_string())),
),
create_search_result_with_replacement(
"file2.txt",
2,
"error2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Error("err2".to_string())),
),
create_search_result_with_replacement(
"file3.txt",
3,
"error3",
LineEnding::Lf,
"repl3",
true,
Some(ReplaceResult::Error("err3".to_string())),
),
],
replacement_errors_pos: 1,
};
state.scroll_replacement_errors_down();
assert_eq!(state.replacement_errors_pos, 2);
state.scroll_replacement_errors_down();
assert_eq!(state.replacement_errors_pos, 0);
state.scroll_replacement_errors_down();
assert_eq!(state.replacement_errors_pos, 1);
}
#[test]
fn test_replace_state_handle_command_results() {
let mut state = ReplaceState {
num_successes: 5,
num_ignored: 2,
errors: vec![
create_search_result_with_replacement(
"file1.txt",
1,
"error1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Error("err1".to_string())),
),
create_search_result_with_replacement(
"file2.txt",
2,
"error2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Error("err2".to_string())),
),
],
replacement_errors_pos: 0,
};
let result = state.handle_command_results(CommandResults::ScrollErrorsDown);
assert!(matches!(result, EventHandlingResult::Rerender));
assert_eq!(state.replacement_errors_pos, 1);
let result = state.handle_command_results(CommandResults::ScrollErrorsUp);
assert!(matches!(result, EventHandlingResult::Rerender));
assert_eq!(state.replacement_errors_pos, 0);
let result = state.handle_command_results(CommandResults::Quit);
assert!(matches!(result, EventHandlingResult::Exit(None)));
}
#[test]
fn test_calculate_statistics_all_success() {
let results = vec![
create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Success),
),
create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Success),
),
create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
true,
Some(ReplaceResult::Success),
),
];
let stats = crate::replace::calculate_statistics(results);
assert_eq!(stats.num_successes, 3);
assert_eq!(stats.errors.len(), 0);
}
#[test]
fn test_calculate_statistics_with_errors() {
let error_result = create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Error("test error".to_string())),
);
let results = vec![
create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Success),
),
error_result.clone(),
create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
true,
Some(ReplaceResult::Success),
),
];
let stats = crate::replace::calculate_statistics(results);
assert_eq!(stats.num_successes, 2);
assert_eq!(stats.errors.len(), 1);
assert_eq!(
stats.errors[0].search_result.path,
error_result.search_result.path
);
}
#[test]
fn test_calculate_statistics_with_none_results() {
let results = vec![
create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"repl1",
true,
Some(ReplaceResult::Success),
),
create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
true,
None,
), create_search_result_with_replacement(
"file3.txt",
3,
"line3",
LineEnding::Lf,
"repl3",
true,
Some(ReplaceResult::Success),
),
];
let stats = crate::replace::calculate_statistics(results);
assert_eq!(stats.num_successes, 2);
assert_eq!(stats.errors.len(), 1);
assert_eq!(
stats.errors[0].search_result.path,
Some(PathBuf::from("file2.txt"))
);
assert_eq!(
stats.errors[0].replace_result,
Some(ReplaceResult::Error(
"Failed to find search result in file".to_owned()
))
);
}
#[test]
fn test_calculate_statistics_with_preview_error_converted() {
let mut preview_errored = create_search_result_with_replacement(
"file1.txt",
1,
"line1",
LineEnding::Lf,
"",
true,
None,
);
preview_errored.preview_error = Some("file unreadable".to_string());
let error = preview_errored.preview_error.take().unwrap();
preview_errored.replace_result = Some(ReplaceResult::Error(error));
let success = create_search_result_with_replacement(
"file2.txt",
2,
"line2",
LineEnding::Lf,
"repl2",
true,
Some(ReplaceResult::Success),
);
let stats = crate::replace::calculate_statistics(vec![preview_errored, success]);
assert_eq!(stats.num_successes, 1);
assert_eq!(stats.errors.len(), 1);
assert_eq!(
stats.errors[0].replace_result,
Some(ReplaceResult::Error("file unreadable".to_string()))
);
}
mod test_helpers {
use crate::search::SearchType;
pub fn create_fixed_search(term: &str) -> SearchType {
SearchType::Fixed(term.to_string())
}
}
fn create_test_file(temp_dir: &TempDir, name: &str, content: &str) -> PathBuf {
let file_path = temp_dir.path().join(name);
std::fs::write(&file_path, content).unwrap();
file_path
}
fn assert_file_content(file_path: &Path, expected_content: &str) {
let content = std::fs::read_to_string(file_path).unwrap();
assert_eq!(content, expected_content);
}
fn fixed_search(pattern: &str) -> SearchType {
SearchType::Fixed(pattern.to_string())
}
fn regex_search(pattern: &str) -> SearchType {
SearchType::Pattern(Regex::new(pattern).unwrap())
}
#[test]
fn test_replace_in_file_success() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nold text\nline 3\nold text\nline 5\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
"old text",
LineEnding::Lf,
"new text",
true,
None,
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
"old text",
LineEnding::Lf,
"new text",
true,
None,
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "line 1\nnew text\nline 3\nnew text\nline 5\n");
}
#[test]
fn test_replace_in_file_success_no_final_newline() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nold text\nline 3\nold text\nline 5",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
"old text",
LineEnding::Lf,
"new text",
true,
None,
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
"old text",
LineEnding::Lf,
"new text",
true,
None,
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
let new_content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(new_content, "line 1\nnew text\nline 3\nnew text\nline 5");
}
#[test]
fn test_replace_in_file_success_windows_newlines() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\r\nold text\r\nline 3\r\nold text\r\nline 5\r\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
"old text",
LineEnding::CrLf,
"new text",
true,
None,
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
"old text",
LineEnding::CrLf,
"new text",
true,
None,
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
let new_content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
new_content,
"line 1\r\nnew text\r\nline 3\r\nnew text\r\nline 5\r\n"
);
}
#[test]
fn test_replace_in_file_success_mixed_newlines() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"\n\r\nline 1\nold text\r\nline 3\nline 4\r\nline 5\r\n\n\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
"old text",
LineEnding::CrLf,
"new text",
true,
None,
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
7,
"line 5",
LineEnding::CrLf,
"updated line 5",
true,
None,
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
let new_content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
new_content,
"\n\r\nline 1\nnew text\r\nline 3\nline 4\r\nupdated line 5\r\n\n\n"
);
}
#[test]
fn test_replace_in_file_line_mismatch() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nactual text\nline 3\n");
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
"expected text",
LineEnding::Lf,
"new text",
true,
None,
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(
results[0].replace_result,
Some(ReplaceResult::Error(
"File changed since last search".to_owned()
))
);
let new_content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(new_content, "line 1\nactual text\nline 3\n");
}
#[test]
fn test_replace_in_file_nonexistent_file() {
let mut results = vec![create_search_result_with_replacement(
"/nonexistent/path/file.txt",
1,
"old",
LineEnding::Lf,
"new",
true,
None,
)];
let result = replace_in_file(&mut results);
assert!(result.is_err());
}
#[test]
fn test_replace_directory_errors() {
let mut results = vec![create_search_result_with_replacement(
"/",
0,
"foo",
LineEnding::Lf,
"bar",
true,
None,
)];
let result = replace_in_file(&mut results);
assert!(result.is_err());
}
#[test]
fn test_replace_in_memory() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"This is a test.\nIt contains search_term that should be replaced.\nMultiple lines with search_term here.",
);
let result = replace_in_memory(&file_path, &fixed_search("search_term"), "replacement");
assert!(result.is_ok());
assert!(result.unwrap());
assert_file_content(
&file_path,
"This is a test.\nIt contains replacement that should be replaced.\nMultiple lines with replacement here.",
);
let regex_path = create_test_file(
&temp_dir,
"regex_test.txt",
"Number: 123, Code: 456, ID: 789",
);
let result = replace_in_memory(®ex_path, ®ex_search(r"\d{3}"), "XXX");
assert!(result.is_ok());
assert!(result.unwrap());
assert_file_content(®ex_path, "Number: XXX, Code: XXX, ID: XXX");
}
#[test]
fn test_replace_in_memory_no_match() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"no_match.txt",
"This is a test file with no matches.",
);
let result = replace_in_memory(&file_path, &fixed_search("nonexistent"), "replacement");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_file_content(&file_path, "This is a test file with no matches.");
}
#[test]
fn test_replace_in_memory_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "empty.txt", "");
let result = replace_in_memory(&file_path, &fixed_search("anything"), "replacement");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_file_content(&file_path, "");
}
#[test]
fn test_replace_in_memory_nonexistent_file() {
let result = replace_in_memory(
Path::new("/nonexistent/path/file.txt"),
&fixed_search("test"),
"replacement",
);
assert!(result.is_err());
}
#[test]
fn test_replace_chunked() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"This is line one.\nThis contains search_pattern to replace.\nAnother line with search_pattern here.\nFinal line.",
);
let result =
replace_line_by_line(&file_path, &fixed_search("search_pattern"), "replacement");
assert!(result.is_ok());
assert!(result.unwrap());
assert_file_content(
&file_path,
"This is line one.\nThis contains replacement to replace.\nAnother line with replacement here.\nFinal line.",
);
let regex_path = create_test_file(
&temp_dir,
"regex.txt",
"Line with numbers: 123 and 456.\nAnother line with 789.",
);
let result = replace_line_by_line(®ex_path, ®ex_search(r"\d{3}"), "XXX");
assert!(result.is_ok());
assert!(result.unwrap());
assert_file_content(
®ex_path,
"Line with numbers: XXX and XXX.\nAnother line with XXX.",
);
}
#[test]
fn test_replace_chunked_no_match() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"This is a test file with no matching patterns.",
);
let result = replace_line_by_line(&file_path, &fixed_search("nonexistent"), "replacement");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_file_content(&file_path, "This is a test file with no matching patterns.");
}
#[test]
fn test_replace_chunked_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "empty.txt", "");
let result = replace_line_by_line(&file_path, &fixed_search("anything"), "replacement");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_file_content(&file_path, "");
}
#[test]
fn test_replace_chunked_nonexistent_file() {
let result = replace_line_by_line(
Path::new("/nonexistent/path/file.txt"),
&fixed_search("test"),
"replacement",
);
assert!(result.is_err());
}
#[test]
fn test_replace_all_in_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"This is a test file.\nIt has some content to replace.\nThe word replace should be replaced.",
);
let result = replace_all_in_file(&file_path, &fixed_search("replace"), "modify", false);
assert!(result.is_ok());
assert!(result.unwrap());
assert_file_content(
&file_path,
"This is a test file.\nIt has some content to modify.\nThe word modify should be modifyd.",
);
}
#[test]
fn test_unicode_in_file() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "Line with Greek: αβγδε").unwrap();
write!(temp_file, "Line with Emoji: 😀 🚀 🌍\r\n").unwrap();
write!(temp_file, "Line with Arabic: مرحبا بالعالم").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Pattern(Regex::new(r"\p{Greek}+").unwrap());
let replacement = "GREEK";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 1);
assert_eq!(results[0].replacement, "Line with Greek: GREEK");
let search = SearchType::Pattern(Regex::new(r"🚀").unwrap());
let replacement = "ROCKET";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 1);
assert_eq!(results[0].replacement, "Line with Emoji: 😀 ROCKET 🌍");
let (_, line_ending) = line_content(&results[0].search_result);
assert_eq!(line_ending, LineEnding::CrLf);
}
mod search_file_tests {
use super::*;
use fancy_regex::Regex as FancyRegex;
use regex::Regex;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_search_file_simple_match() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "line 1").unwrap();
writeln!(temp_file, "search target").unwrap();
writeln!(temp_file, "line 3").unwrap();
temp_file.flush().unwrap();
let search = test_helpers::create_fixed_search("search");
let replacement = "replace";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 1);
assert_eq!(results[0].search_result.start_line_number(), 2);
let (content, _) = line_content(&results[0].search_result);
assert_eq!(content, "search target");
assert_eq!(results[0].replacement, "replace target");
assert!(results[0].search_result.included);
}
#[test]
fn test_search_file_multiple_matches() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "test line 1").unwrap();
writeln!(temp_file, "test line 2").unwrap();
writeln!(temp_file, "no match here").unwrap();
writeln!(temp_file, "test line 4").unwrap();
temp_file.flush().unwrap();
let search = test_helpers::create_fixed_search("test");
let replacement = "replaced";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 3);
assert_eq!(results[0].search_result.start_line_number(), 1);
assert_eq!(results[0].replacement, "replaced line 1");
assert_eq!(results[1].search_result.start_line_number(), 2);
assert_eq!(results[1].replacement, "replaced line 2");
assert_eq!(results[2].search_result.start_line_number(), 4);
assert_eq!(results[2].replacement, "replaced line 4");
}
#[test]
fn test_search_file_no_matches() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "line 1").unwrap();
writeln!(temp_file, "line 2").unwrap();
writeln!(temp_file, "line 3").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Fixed("nonexistent".to_string());
let replacement = "replace";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 0);
}
#[test]
fn test_search_file_regex_pattern() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "number: 123").unwrap();
writeln!(temp_file, "text without numbers").unwrap();
writeln!(temp_file, "another number: 456").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Pattern(Regex::new(r"\d+").unwrap());
let replacement = "XXX";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 2);
assert_eq!(results[0].replacement, "number: XXX");
assert_eq!(results[1].replacement, "another number: XXX");
}
#[test]
fn test_search_file_advanced_regex_pattern() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "123abc456").unwrap();
writeln!(temp_file, "abc").unwrap();
writeln!(temp_file, "789xyz123").unwrap();
writeln!(temp_file, "no match").unwrap();
temp_file.flush().unwrap();
let search =
SearchType::PatternAdvanced(FancyRegex::new(r"(?<=\d{3})abc(?=\d{3})").unwrap());
let replacement = "REPLACED";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 1);
assert_eq!(results[0].replacement, "123REPLACED456");
assert_eq!(results[0].search_result.start_line_number(), 1);
}
#[test]
fn test_search_file_empty_search() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "some content").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Fixed("".to_string());
let replacement = "replace";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 0);
}
#[test]
fn test_search_file_preserves_line_endings() {
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "line1\nline2\r\nline3").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Fixed("line".to_string());
let replacement = "X";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 3);
let (_, le0) = line_content(&results[0].search_result);
assert_eq!(le0, LineEnding::Lf);
let (_, le1) = line_content(&results[1].search_result);
assert_eq!(le1, LineEnding::CrLf);
let (_, le2) = line_content(&results[2].search_result);
assert_eq!(le2, LineEnding::None);
}
#[test]
fn test_search_file_nonexistent() {
let nonexistent_path = PathBuf::from("/this/file/does/not/exist.txt");
let search = test_helpers::create_fixed_search("test");
let results = search_file(&nonexistent_path, &search, false);
assert!(results.is_err());
}
#[test]
fn test_search_file_unicode_content() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "Hello 世界!").unwrap();
writeln!(temp_file, "Здравствуй мир!").unwrap();
writeln!(temp_file, "🚀 Rocket").unwrap();
temp_file.flush().unwrap();
let search = SearchType::Fixed("世界".to_string());
let replacement = "World";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 1);
assert_eq!(results[0].replacement, "Hello World!");
}
#[test]
fn test_search_file_with_binary_content() {
let mut temp_file = NamedTempFile::new().unwrap();
let binary_data = [0x00, 0x01, 0x02, 0xFF, 0xFE];
temp_file.write_all(&binary_data).unwrap();
temp_file.flush().unwrap();
let search = test_helpers::create_fixed_search("test");
let replacement = "replace";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 0);
}
#[test]
fn test_search_file_large_content() {
let mut temp_file = NamedTempFile::new().unwrap();
for i in 0..1000 {
if i % 100 == 0 {
writeln!(temp_file, "target line {i}").unwrap();
} else {
writeln!(temp_file, "normal line {i}").unwrap();
}
}
temp_file.flush().unwrap();
let search = SearchType::Fixed("target".to_string());
let replacement = "found";
let results = search_file(temp_file.path(), &search, false)
.unwrap()
.into_iter()
.filter_map(|r| add_replacement(r, &search, replacement))
.collect::<Vec<_>>();
assert_eq!(results.len(), 10); assert_eq!(results[0].search_result.start_line_number(), 1); assert_eq!(results[1].search_result.start_line_number(), 101);
assert_eq!(results[9].search_result.start_line_number(), 901);
}
}
mod replace_if_match_tests {
use crate::validation::SearchConfig;
use super::*;
mod test_helpers {
use crate::{
search::ParsedSearchConfig,
validation::{
SearchConfig, SimpleErrorHandler, ValidationResult,
validate_search_configuration,
},
};
pub fn must_parse_search_config(search_config: SearchConfig<'_>) -> ParsedSearchConfig {
let mut error_handler = SimpleErrorHandler::new();
let (search_config, _dir_config) =
match validate_search_configuration(search_config, None, &mut error_handler)
.unwrap()
{
ValidationResult::Success(search_config) => search_config,
ValidationResult::ValidationErrors => {
panic!("{}", error_handler.errors_str().unwrap());
}
};
search_config
}
}
mod fixed_string_tests {
use super::*;
mod whole_word_true_match_case_true {
use super::*;
#[test]
fn test_basic_replacement() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_word_boundaries() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
}
}
mod whole_word_true_match_case_false {
use super::*;
#[test]
fn test_basic_replacement() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_insensitivity() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_word_boundaries() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_unicode() {
let search_config = SearchConfig {
search_text: "café",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "restaurant",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("Hello CAFÉ table", &parsed.search, &parsed.replace),
Some("Hello restaurant table".to_string())
);
}
}
mod whole_word_false_match_case_true {
use super::*;
#[test]
fn test_basic_replacement() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_substring_matches() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
Some("earthwide".to_string())
);
}
}
mod whole_word_false_match_case_false {
use super::*;
#[test]
fn test_basic_replacement() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_insensitivity() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_substring_matches() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: false,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
Some("earthWIDE".to_string())
);
}
}
}
mod regex_pattern_tests {
use super::*;
mod whole_word_true_match_case_true {
use crate::validation::SearchConfig;
use super::*;
#[test]
fn test_basic_regex() {
let re_str = r"w\w+d";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_word_boundaries() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
}
}
mod whole_word_true_match_case_false {
use super::*;
#[test]
fn test_basic_regex() {
let re_str = r"w\w+d";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_word_boundaries() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_special_characters() {
let re_str = r"\d+";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NUM",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("test 123 number", &parsed.search, &parsed.replace),
Some("test NUM number".to_string())
);
}
#[test]
fn test_unicode_word_boundaries() {
let re_str = r"\b\p{Script=Han}{2}\b";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "XX",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert!(
replace_all_if_match("Text 世界 more", &parsed.search, &parsed.replace)
.is_some()
);
assert!(replace_all_if_match("Text世界more", &parsed.search, "XX").is_none());
}
}
mod whole_word_false_match_case_true {
use super::*;
#[test]
fn test_basic_regex() {
let re_str = r"w\w+d";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_substring_matches() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
Some("earthwide".to_string())
);
}
}
mod whole_word_false_match_case_false {
use super::*;
#[test]
fn test_basic_regex() {
let re_str = r"w\w+d";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
}
#[test]
fn test_substring_matches() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("WORLDWIDE", &parsed.search, &parsed.replace),
Some("earthWIDE".to_string())
);
}
#[test]
fn test_complex_pattern() {
let re_str = r"\d{3}-\d{2}-\d{4}";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: false,
match_case: false,
replacement_text: "XXX-XX-XXXX",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("SSN: 123-45-6789", &parsed.search, &parsed.replace),
Some("SSN: XXX-XX-XXXX".to_string())
);
}
}
}
mod fancy_regex_pattern_tests {
use super::*;
mod whole_word_true_match_case_true {
use super::*;
#[test]
fn test_lookbehind() {
let re_str = r"(?<=@)\w+";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: true,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: true,
replacement_text: "domain",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match(
"email: user@example.com",
&parsed.search,
&parsed.replace
),
Some("email: user@domain.com".to_string())
);
}
#[test]
fn test_lookahead() {
let re_str = r"\w+(?=\.\w+$)";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: true,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: true,
replacement_text: "report",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("file: document.pdf", &parsed.search, &parsed.replace),
Some("file: report.pdf".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: true,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: true,
replacement_text: "earth",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello WORLD", &parsed.search, &parsed.replace),
None
);
}
}
mod whole_word_true_match_case_false {
use super::*;
#[test]
fn test_lookbehind_case_insensitive() {
let re_str = r"(?<=@)\w+";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: true,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: false,
replacement_text: "domain",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match(
"email: user@EXAMPLE.com",
&parsed.search,
&parsed.replace
),
Some("email: user@domain.com".to_string())
);
}
#[test]
fn test_word_boundaries() {
let re_str = r"world";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: true,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: false,
replacement_text: "earth",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
}
}
mod whole_word_false_match_case_true {
use super::*;
#[test]
fn test_complex_pattern() {
let re_str = r"(?<=\d{4}-\d{2}-\d{2}T)\d{2}:\d{2}";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: false,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: true,
replacement_text: "XX:XX",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match(
"Timestamp: 2023-01-15T14:30:00Z",
&parsed.search,
&parsed.replace
),
Some("Timestamp: 2023-01-15TXX:XX:00Z".to_string())
);
}
#[test]
fn test_case_sensitivity() {
let re_str = r"WORLD";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: false,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: true,
replacement_text: "earth",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
None
);
}
}
mod whole_word_false_match_case_false {
use super::*;
#[test]
fn test_complex_pattern_case_insensitive() {
let re_str = r"(?<=\[)\w+(?=\])";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: false,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: false,
replacement_text: "ERROR",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match(
"Tag: [WARNING] message",
&parsed.search,
&parsed.replace
),
Some("Tag: [ERROR] message".to_string())
);
}
#[test]
fn test_unicode_support() {
let re_str = r"\p{Greek}+";
let search_config = SearchConfig {
search_text: re_str,
match_whole_word: false,
fixed_strings: false,
advanced_regex: true,
multiline: false,
match_case: false,
replacement_text: "GREEK",
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("Symbol: αβγδ", &parsed.search, &parsed.replace),
Some("Symbol: GREEK".to_string())
);
}
}
}
#[test]
fn test_multiple_replacements() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("world hello world", &parsed.search, &parsed.replace),
Some("earth hello earth".to_string())
);
}
#[test]
fn test_no_match() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide", &parsed.search, &parsed.replace),
None
);
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("_world_", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_word_boundaries() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match(",world-", &parsed.search, &parsed.replace),
Some(",earth-".to_string())
);
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("world-word", &parsed.search, &parsed.replace),
Some("earth-word".to_string())
);
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("Hello-world!", &parsed.search, &parsed.replace),
Some("Hello-earth!".to_string())
);
}
#[test]
fn test_case_sensitive() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("Hello WORLD", &parsed.search, &parsed.replace),
None
);
let search_config = SearchConfig {
search_text: "wOrld",
fixed_strings: true,
match_whole_word: true,
match_case: true,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("Hello world", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_empty_strings() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("", &parsed.search, &parsed.replace),
None
);
let search_config = SearchConfig {
search_text: "",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_substring_no_match() {
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("worldwide web", &parsed.search, &parsed.replace),
None
);
let search_config = SearchConfig {
search_text: "world",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("underworld", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_special_regex_chars() {
let search_config = SearchConfig {
search_text: "(world)",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello (world)", &parsed.search, &parsed.replace),
Some("hello earth".to_string())
);
let search_config = SearchConfig {
search_text: "world.*",
fixed_strings: true,
match_whole_word: true,
match_case: false,
replacement_text: "ea+rth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("hello world.*", &parsed.search, &parsed.replace),
Some("hello ea+rth".to_string())
);
}
#[test]
fn test_basic_regex_patterns() {
let re_str = r"ax*b";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("foo axxxxb bar", &parsed.search, &parsed.replace),
Some("foo NEW bar".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("fooaxxxxb bar", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_patterns_with_spaces() {
let re_str = r"hel+o world";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "hi earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("say hello world!", &parsed.search, &parsed.replace),
Some("say hi earth!".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "hi earth",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("helloworld", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_multiple_matches() {
let re_str = r"a+b+";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("foo aab abb", &parsed.search, &parsed.replace),
Some("foo X X".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("ab abaab abb", &parsed.search, &parsed.replace),
Some("X abaab X".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("ababaababb", &parsed.search, &parsed.replace),
None
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("ab ab aab abb", &parsed.search, &parsed.replace),
Some("X X X X".to_string())
);
}
#[test]
fn test_boundary_cases() {
let re_str = r"foo\s*bar";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "TEST",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("foo bar baz", &parsed.search, &parsed.replace),
Some("TEST baz".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "TEST",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("baz foo bar", &parsed.search, &parsed.replace),
Some("baz TEST".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "TEST",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("a (?( foo bar)", &parsed.search, &parsed.replace),
Some("a (?( TEST)".to_string())
);
}
#[test]
fn test_with_punctuation() {
let re_str = r"a\d+b";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("(a42b)", &parsed.search, &parsed.replace),
Some("(X)".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("foo.a123b!bar", &parsed.search, &parsed.replace),
Some("foo.X!bar".to_string())
);
}
#[test]
fn test_complex_patterns() {
let re_str = r"[a-z]+\d+[a-z]+";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("test9 abc123def 8xyz", &parsed.search, &parsed.replace),
Some("test9 NEW 8xyz".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("test9abc123def8xyz", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_optional_patterns() {
let re_str = r"colou?r";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("my color and colour", &parsed.search, &parsed.replace),
Some("my X and X".to_string())
);
}
#[test]
fn test_empty_haystack() {
let re_str = r"test";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_empty_search_regex() {
let re_str = r"";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "NEW",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("search", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_single_char() {
let re_str = r"a";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("b a c", &parsed.search, &parsed.replace),
Some("b X c".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("bac", &parsed.search, &parsed.replace),
None
);
}
#[test]
fn test_escaped_chars() {
let re_str = r"\(\d+\)";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("test (123) foo", &parsed.search, &parsed.replace),
Some("test X foo".to_string())
);
}
#[test]
fn test_with_unicode() {
let re_str = r"λ\d+";
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("calc λ123 β", &parsed.search, &parsed.replace),
Some("calc X β".to_string())
);
let search_config = SearchConfig {
search_text: re_str,
fixed_strings: false,
match_whole_word: true,
match_case: false,
replacement_text: "X",
advanced_regex: false,
multiline: false,
interpret_escape_sequences: false,
};
let parsed = test_helpers::must_parse_search_config(search_config);
assert_eq!(
replace_all_if_match("calcλ123", &parsed.search, &parsed.replace),
None
);
}
}
#[cfg(unix)]
mod permission_preservation_tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
const MODE_PERMISSIONS_MASK: u32 = 0o777;
fn assert_permissions_preserved(file_path: &Path, expected_mode: u32) {
let final_perms = std::fs::metadata(file_path).unwrap().permissions();
assert_eq!(final_perms.mode() & MODE_PERMISSIONS_MASK, expected_mode);
}
#[test]
fn test_replace_in_file_preserves_permissions() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
"old text",
LineEnding::Lf,
"new text",
true,
None,
)];
replace_in_file(&mut results).unwrap();
assert_permissions_preserved(&file_path, 0o644);
}
#[test]
fn test_replace_in_memory_preserves_permissions() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let result = replace_in_memory(&file_path, &fixed_search("old"), "new").unwrap();
assert!(result);
assert_permissions_preserved(&file_path, 0o755);
}
#[test]
fn test_replace_preserves_restrictive_permissions() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
"old text",
LineEnding::Lf,
"new text",
true,
None,
)];
replace_in_file(&mut results).unwrap();
assert_permissions_preserved(&file_path, 0o600);
}
#[test]
fn test_replace_preserves_permissive_permissions() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "old text\n");
std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o777)).unwrap();
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
"old text",
LineEnding::Lf,
"new text",
true,
None,
)];
replace_in_file(&mut results).unwrap();
assert_permissions_preserved(&file_path, 0o777);
}
}
mod multiline_replace_tests {
use super::*;
use crate::search::{ByteRangeParams, Line, search_multiline};
fn create_single_line_byte_range_result(
path: &Path,
line_number: usize,
line_content: &str,
match_start: usize,
match_end: usize,
byte_start: usize,
replacement: &str,
) -> SearchResultWithReplacement {
let expected_content = line_content[match_start..match_end].to_string();
let byte_end = byte_start + expected_content.len();
SearchResultWithReplacement {
search_result: SearchResult::new_byte_range(ByteRangeParams {
path: Some(path.to_path_buf()),
lines: vec![(
line_number,
Line {
content: line_content.to_string(),
line_ending: LineEnding::Lf,
},
)],
match_start_in_first_line: match_start,
match_end_in_last_line: match_end,
byte_start,
byte_end,
content: expected_content,
included: true,
}),
replacement: replacement.to_string(),
replace_result: None,
preview_error: None,
}
}
fn create_byte_range_result(
path: &str,
start_line: usize,
end_line: usize,
byte_start: usize,
byte_end: usize,
content: &str,
replacement: &str,
) -> SearchResultWithReplacement {
let mut lines: Vec<(usize, Line)> = Vec::new();
let mut line_num = start_line;
let mut remaining = content;
while !remaining.is_empty() {
let (content, line_ending, rest) = if let Some(crlf_pos) = remaining.find("\r\n") {
let lf_pos = remaining.find('\n');
if let Some(pos) = lf_pos
&& pos < crlf_pos
{
(&remaining[..pos], LineEnding::Lf, &remaining[pos + 1..])
} else {
(
&remaining[..crlf_pos],
LineEnding::CrLf,
&remaining[crlf_pos + 2..],
)
}
} else if let Some(pos) = remaining.find('\n') {
(&remaining[..pos], LineEnding::Lf, &remaining[pos + 1..])
} else {
(remaining, LineEnding::None, "")
};
lines.push((
line_num,
Line {
content: content.to_string(),
line_ending,
},
));
line_num += 1;
remaining = rest;
}
let computed_end_line = start_line + lines.len() - 1;
assert_eq!(
computed_end_line,
end_line,
"Line count mismatch: content has {} lines (ending at line {}), but end_line was {}",
lines.len(),
computed_end_line,
end_line
);
let match_start_in_first_line = 0;
let match_end_in_last_line = if let Some(last_line) = lines.last() {
last_line.1.content.len()
} else {
0
};
SearchResultWithReplacement {
search_result: SearchResult::new_byte_range(ByteRangeParams {
path: Some(PathBuf::from(path)),
lines,
match_start_in_first_line,
match_end_in_last_line,
byte_start,
byte_end,
content: content.to_string(),
included: true,
}),
replacement: replacement.to_string(),
replace_result: None,
preview_error: None,
}
}
fn create_search_result_with_replacement(
path: &str,
start_line: usize,
lines_content: &[(&str, LineEnding)],
replacement: &str,
) -> SearchResultWithReplacement {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path).expect("Failed to open test file");
let reader = BufReader::new(file);
let mut byte_start = 0;
let mut current_line = 1;
for line_result in reader.lines() {
if current_line >= start_line {
break;
}
let line = line_result.expect("Failed to read line");
byte_start += line.len() + 1; current_line += 1;
}
let content = lines_content
.iter()
.fold(String::new(), |mut acc, (content, ending)| {
use std::fmt::Write;
write!(acc, "{}{}", content, ending.as_str()).unwrap();
acc
});
let byte_end = byte_start + content.len();
let end_line = start_line + lines_content.len() - 1;
create_byte_range_result(
path,
start_line,
end_line,
byte_start,
byte_end,
&content,
replacement,
)
}
#[test]
fn test_single_multiline_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\n",
);
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
2,
4,
7,
28,
"line 2\nline 3\nline 4\n",
"REPLACED\n",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "line 1\nREPLACED\nline 5\n");
}
#[test]
fn test_non_overlapping_multiline_replacements() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
);
let mut results = vec![
create_byte_range_result(
file_path.to_str().unwrap(),
1,
2,
0,
14,
"line 1\nline 2\n",
"FIRST\n",
),
create_byte_range_result(
file_path.to_str().unwrap(),
5,
7,
28,
49,
"line 5\nline 6\nline 7\n",
"SECOND\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "FIRST\nline 3\nline 4\nSECOND\n");
}
#[test]
fn test_conflict_overlapping_ranges() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\n",
);
let mut results = vec![
create_byte_range_result(
file_path.to_str().unwrap(),
2,
4,
7,
28,
"line 2\nline 3\nline 4\n",
"FIRST\n",
),
create_byte_range_result(
file_path.to_str().unwrap(),
3,
5,
14,
35,
"line 3\nline 4\nline 5\n",
"SECOND\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(matches!(
results[1].replace_result,
Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
));
assert_file_content(&file_path, "line 1\nFIRST\nline 5\n");
}
#[test]
fn test_multiple_overlapping_conflicts() {
let temp_dir = TempDir::new().unwrap();
let file_content = (1..=15)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let file_path = create_test_file(&temp_dir, "test.txt", &file_content);
let mut results = vec![
create_byte_range_result(
file_path.to_str().unwrap(),
9,
11,
56,
79,
"line 9\nline 10\nline 11\n",
"FIRST\n",
),
create_byte_range_result(
file_path.to_str().unwrap(),
10,
13,
63,
95,
"line 10\nline 11\nline 12\nline 13\n",
"SECOND\n",
),
create_byte_range_result(
file_path.to_str().unwrap(),
12,
12,
79,
87,
"line 12\n",
"THIRD\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(matches!(
results[1].replace_result,
Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
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";
assert_file_content(&file_path, expected);
}
#[test]
fn test_conflict_detection_byte_offsets_no_overlap() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "abc def ghi\n");
let line_content = "abc def ghi";
let mut results = vec![
create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
create_single_line_byte_range_result(&file_path, 1, line_content, 4, 7, 4, "YYY"),
create_single_line_byte_range_result(&file_path, 1, line_content, 8, 11, 8, "ZZZ"),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "XXX YYY ZZZ\n");
}
#[test]
fn test_conflict_detection_byte_offsets_with_overlap() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "abcdef\n");
let line_content = "abcdef";
let mut results = vec![
create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
create_single_line_byte_range_result(&file_path, 1, line_content, 2, 6, 2, "YYY"),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
assert_file_content(&file_path, "XXXdef\n");
}
#[test]
fn test_conflict_detection_byte_offsets_adjacent() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "abcdef\n");
let line_content = "abcdef";
let mut results = vec![
create_single_line_byte_range_result(&file_path, 1, line_content, 0, 3, 0, "XXX"),
create_single_line_byte_range_result(&file_path, 1, line_content, 2, 6, 2, "YYY"),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
assert_file_content(&file_path, "XXXdef\n");
}
#[test]
fn test_conflict_detection_line_level_adjacent() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\nline 3\n");
let mut results = vec![
SearchResultWithReplacement {
search_result: SearchResult::new_line(
Some(file_path.clone()),
1,
"line 1".to_string(),
LineEnding::Lf,
true,
),
replacement: "XXX\n".to_string(),
replace_result: None,
preview_error: None,
},
SearchResultWithReplacement {
search_result: SearchResult::new_line(
Some(file_path),
2,
"line 2".to_string(),
LineEnding::Lf,
true,
),
replacement: "YYY\n".to_string(),
replace_result: None,
preview_error: None,
},
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
}
#[test]
fn test_adjacent_non_overlapping() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[
("line 1", LineEnding::Lf),
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
],
"FIRST\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
&[
("line 4", LineEnding::Lf),
("line 5", LineEnding::Lf),
("line 6", LineEnding::Lf),
],
"SECOND\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "FIRST\nSECOND\nline 7\n");
}
#[test]
fn test_partial_overlap() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[
("line 1", LineEnding::Lf),
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
("line 5", LineEnding::Lf),
],
"FIRST\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
3,
&[
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
("line 5", LineEnding::Lf),
("line 6", LineEnding::Lf),
("line 7", LineEnding::Lf),
("line 8", LineEnding::Lf),
],
"SECOND\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(matches!(
results[1].replace_result,
Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
));
assert_file_content(&file_path, "FIRST\nline 6\nline 7\nline 8\n");
}
#[test]
fn test_single_line_between_multiline() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[
("line 1", LineEnding::Lf),
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
],
"FIRST\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
&[("line 2", LineEnding::Lf)],
"MIDDLE\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
4,
&[
("line 4", LineEnding::Lf),
("line 5", LineEnding::Lf),
("line 6", LineEnding::Lf),
],
"LAST\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(matches!(
results[1].replace_result,
Some(ReplaceResult::Error(ref msg)) if msg.contains("Conflicts")
));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "FIRST\nLAST\nline 7\n");
}
#[test]
fn test_multiline_at_end_of_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5",
);
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
3,
&[
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
("line 5", LineEnding::None),
],
"END", )];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "line 1\nline 2\nEND");
}
#[test]
fn test_multiline_no_newline_in_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5",
);
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
2,
&[
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
],
"REPLACEMENT",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "line 1\nREPLACEMENTline 5");
}
#[test]
fn test_multiple_multiline_with_gaps() {
let temp_dir = TempDir::new().unwrap();
let file_content = (1..=15)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let file_path = create_test_file(&temp_dir, "test.txt", &file_content);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[("line 1", LineEnding::Lf), ("line 2", LineEnding::Lf)],
"A\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
5,
&[
("line 5", LineEnding::Lf),
("line 6", LineEnding::Lf),
("line 7", LineEnding::Lf),
],
"B\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
10,
&[
("line 10", LineEnding::Lf),
("line 11", LineEnding::Lf),
("line 12", LineEnding::Lf),
],
"C\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
let expected = "A\nline 3\nline 4\nB\nline 8\nline 9\nC\nline 13\nline 14\nline 15\n";
assert_file_content(&file_path, expected);
}
#[test]
fn test_file_changed_multiline_validation() {
let temp_dir = TempDir::new().unwrap();
let file_path =
create_test_file(&temp_dir, "test.txt", "line 1\nCHANGED\nline 3\nline 4\n");
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[
("line 1", LineEnding::Lf),
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
],
"REPLACED\n",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(matches!(
results[0].replace_result,
Some(ReplaceResult::Error(ref msg)) if msg.contains("File changed")
));
assert_file_content(&file_path, "line 1\nCHANGED\nline 3\nline 4\n");
}
#[test]
fn test_file_too_short_multiline() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\n");
let mut results = vec![create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[
("line 1", LineEnding::Lf),
("line 2", LineEnding::Lf),
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
],
"REPLACED\n",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(results[0].replace_result.is_none());
assert_file_content(&file_path, "line 1\nline 2\n");
}
#[test]
fn test_mixed_single_and_multiline() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[("line 1", LineEnding::Lf)],
"SINGLE\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
3,
&[
("line 3", LineEnding::Lf),
("line 4", LineEnding::Lf),
("line 5", LineEnding::Lf),
],
"MULTI\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
6,
&[("line 6", LineEnding::Lf)],
"SINGLE2\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "SINGLE\nline 2\nMULTI\nSINGLE2\n");
}
#[test]
fn test_unsorted_input() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(
&temp_dir,
"test.txt",
"line 1\nline 2\nline 3\nline 4\nline 5\n",
);
let mut results = vec![
create_search_result_with_replacement(
file_path.to_str().unwrap(),
5,
&[("line 5", LineEnding::Lf)],
"LAST\n",
),
create_search_result_with_replacement(
file_path.to_str().unwrap(),
1,
&[("line 1", LineEnding::Lf), ("line 2", LineEnding::Lf)],
"FIRST\n",
),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "FIRST\nline 3\nline 4\nLAST\n");
}
#[test]
fn test_multiple_matches_same_line_no_conflict() {
let temp_dir = TempDir::new().unwrap();
let file_path =
create_test_file(&temp_dir, "test.txt", "foo\nbar baz bar qux\nbar\nbux\n");
let content = std::fs::read_to_string(&file_path).unwrap();
let search = SearchType::Fixed("bar".to_string());
let search_results = search_multiline(&content, &search, Some(&file_path));
assert_eq!(search_results.len(), 3);
assert_eq!(search_results[0].start_line_number(), 2);
assert_eq!(search_results[0].end_line_number(), 2);
assert_eq!(byte_range_bytes(&search_results[0]), (4, 7));
assert_eq!(byte_range_content(&search_results[0]), "bar");
assert_eq!(search_results[1].start_line_number(), 2);
assert_eq!(search_results[1].end_line_number(), 2);
assert_eq!(byte_range_bytes(&search_results[1]), (12, 15));
assert_eq!(byte_range_content(&search_results[1]), "bar");
assert_eq!(search_results[2].start_line_number(), 3);
assert_eq!(search_results[2].end_line_number(), 3);
assert_eq!(byte_range_bytes(&search_results[2]), (20, 23));
assert_eq!(byte_range_content(&search_results[2]), "bar");
let mut results: Vec<SearchResultWithReplacement> = search_results
.into_iter()
.map(|sr| SearchResultWithReplacement {
search_result: sr,
replacement: "REPLACED".to_string(),
replace_result: None,
preview_error: None,
})
.collect();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(
&file_path,
"foo\nREPLACED baz REPLACED qux\nREPLACED\nbux\n",
);
}
#[test]
fn test_multiple_matches_same_line_all_replaced() {
let temp_dir = TempDir::new().unwrap();
let file_path =
create_test_file(&temp_dir, "test.txt", "foo\nbar baz bar qux\nbar\nbux\n");
let content = std::fs::read_to_string(&file_path).unwrap();
let search = SearchType::Fixed("bar".to_string());
let search_results = search_multiline(&content, &search, Some(&file_path));
assert_eq!(search_results.len(), 3);
assert_eq!(byte_range_bytes(&search_results[0]), (4, 7));
assert_eq!(byte_range_bytes(&search_results[1]), (12, 15));
assert_eq!(byte_range_bytes(&search_results[2]), (20, 23));
let mut results: Vec<SearchResultWithReplacement> = search_results
.into_iter()
.map(|sr| SearchResultWithReplacement {
search_result: sr,
replacement: "REPLACED".to_string(),
replace_result: None,
preview_error: None,
})
.collect();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(
&file_path,
"foo\nREPLACED baz REPLACED qux\nREPLACED\nbux\n",
);
}
}
mod mark_conflicting_replacements_tests {
use super::{super::mark_conflicting_replacements, *};
use crate::search::{ByteRangeParams, Line};
fn create_replacement_result(
start_line: usize,
end_line: usize,
byte_start: usize,
byte_end: usize,
) -> SearchResultWithReplacement {
let content = format!("content-{byte_start}-{byte_end}");
let lines: Vec<(usize, Line)> = (start_line..=end_line)
.map(|line_num| {
(
line_num,
Line {
content: format!("line {line_num}"),
line_ending: LineEnding::Lf,
},
)
})
.collect();
let last_line_content_len = lines.last().map_or(0, |(_, l)| l.content.len());
SearchResultWithReplacement {
search_result: SearchResult::new_byte_range(ByteRangeParams {
path: Some(PathBuf::from("test.txt")),
lines,
match_start_in_first_line: 0,
match_end_in_last_line: last_line_content_len,
byte_start,
byte_end,
content,
included: true,
}),
replacement: "REPLACED".to_string(),
replace_result: None,
preview_error: None,
}
}
#[test]
fn test_no_conflicts_sequential_byte_ranges() {
let mut results = vec![
create_replacement_result(1, 1, 0, 9),
create_replacement_result(2, 2, 10, 19),
create_replacement_result(3, 3, 20, 29),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 3);
assert_eq!(results[0].replace_result, None);
assert_eq!(results[1].replace_result, None);
assert_eq!(results[2].replace_result, None);
}
#[test]
fn test_conflict_overlapping_byte_ranges() {
let mut results = vec![
create_replacement_result(1, 1, 0, 10),
create_replacement_result(1, 1, 5, 15),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, None);
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
}
#[test]
fn test_conflict_overlapping_multiline_byte_ranges() {
let mut results = vec![
create_replacement_result(1, 3, 0, 17),
create_replacement_result(2, 4, 6, 23),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, None);
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
}
#[test]
fn test_no_conflict_adjacent_multiline_byte_ranges() {
let mut results = vec![
create_replacement_result(1, 3, 0, 17),
create_replacement_result(4, 6, 18, 35),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, None);
assert_eq!(results[1].replace_result, None);
}
#[test]
fn test_byte_offsets_no_overlap_same_line() {
let mut results = vec![
create_replacement_result(1, 1, 0, 5),
create_replacement_result(1, 1, 6, 10),
create_replacement_result(1, 1, 11, 15),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 3);
assert_eq!(results[0].replace_result, None);
assert_eq!(results[1].replace_result, None);
assert_eq!(results[2].replace_result, None);
}
#[test]
fn test_byte_offsets_touching_no_conflict() {
let mut results = vec![
create_replacement_result(1, 1, 0, 5),
create_replacement_result(1, 1, 5, 10),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, None);
assert_eq!(results[1].replace_result, None);
}
#[test]
fn test_byte_offsets_overlap_conflict() {
let mut results = vec![
create_replacement_result(1, 1, 0, 10),
create_replacement_result(1, 1, 5, 15),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 2);
assert_eq!(results[0].replace_result, None);
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
}
#[test]
fn test_byte_offsets_across_lines() {
let mut results = vec![
create_replacement_result(1, 1, 0, 5),
create_replacement_result(2, 2, 10, 15),
create_replacement_result(2, 2, 16, 20),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 3);
assert_eq!(results[0].replace_result, None);
assert_eq!(results[1].replace_result, None);
assert_eq!(results[2].replace_result, None);
}
#[test]
fn test_sorting_by_byte_offset() {
let mut results = vec![
create_replacement_result(2, 2, 10, 15),
create_replacement_result(1, 1, 5, 8),
create_replacement_result(1, 1, 0, 3),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 3);
assert_eq!(byte_range_bytes(&results[0].search_result), (0, 3));
assert_eq!(results[0].replace_result, None);
assert_eq!(byte_range_bytes(&results[1].search_result), (5, 8));
assert_eq!(results[1].replace_result, None);
assert_eq!(byte_range_bytes(&results[2].search_result), (10, 15));
assert_eq!(results[2].replace_result, None);
}
#[test]
fn test_chain_of_overlapping_conflicts() {
let mut results = vec![
create_replacement_result(1, 1, 0, 10),
create_replacement_result(1, 1, 5, 15),
create_replacement_result(1, 2, 10, 20),
];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 3);
assert_eq!(results[0].replace_result, None);
assert_eq!(
results[1].replace_result,
Some(ReplaceResult::Error(
"Conflicts with previous replacement".to_owned()
))
);
assert_eq!(results[2].replace_result, None);
}
#[test]
fn test_empty_results() {
let mut results: Vec<SearchResultWithReplacement> = vec![];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 0);
}
#[test]
fn test_single_result() {
let mut results = vec![create_replacement_result(1, 1, 0, 10)];
mark_conflicting_replacements(&mut results);
assert_eq!(results.len(), 1);
assert_eq!(results[0].replace_result, None);
}
}
mod byte_mode_replace_tests {
use super::*;
use crate::search::{ByteRangeParams, Line};
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, content).unwrap();
path
}
fn assert_file_content(path: &Path, expected: &str) {
let actual = std::fs::read_to_string(path).unwrap();
assert_eq!(actual, expected, "File content mismatch");
}
fn create_byte_range_result(
path: &str,
start_line: usize,
end_line: usize,
byte_start: usize,
byte_end: usize,
content: &str,
replacement: &str,
) -> SearchResultWithReplacement {
let lines: Vec<(usize, Line)> = (start_line..=end_line)
.map(|line_num| {
(
line_num,
Line {
content: format!("line {line_num}"),
line_ending: LineEnding::Lf,
},
)
})
.collect();
let last_line_content_len = lines.last().map_or(0, |(_, l)| l.content.len());
SearchResultWithReplacement {
search_result: SearchResult::new_byte_range(ByteRangeParams {
path: Some(PathBuf::from(path)),
lines,
match_start_in_first_line: 0,
match_end_in_last_line: last_line_content_len,
byte_start,
byte_end,
content: content.to_string(),
included: true,
}),
replacement: replacement.to_string(),
replace_result: None,
preview_error: None,
}
}
#[test]
fn test_byte_mode_happy_path_single_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"rust",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello rust");
}
#[test]
fn test_byte_mode_happy_path_multiple_replacements() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz qux");
let mut results = vec![
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 12, 15, "qux", "DDD"),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "AAA bar CCC DDD");
}
#[test]
fn test_byte_mode_replacement_at_start() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
0,
5,
"hello",
"hi",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hi world");
}
#[test]
fn test_byte_mode_zero_length_insertion() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![SearchResultWithReplacement {
search_result: SearchResult::new_byte_range(ByteRangeParams {
path: Some(file_path.clone()),
lines: vec![(
1,
Line {
content: "hello world".to_string(),
line_ending: LineEnding::Lf,
},
)],
match_start_in_first_line: 5,
match_end_in_last_line: 5,
byte_start: 5,
byte_end: 5, content: "".to_string(),
included: true,
}),
replacement: "X".to_string(),
replace_result: None,
preview_error: None,
}];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "helloX world");
}
#[test]
fn test_byte_mode_replacement_at_end() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"everyone",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello everyone");
}
#[test]
fn test_byte_mode_file_content_changed() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world", "rust",
)];
std::fs::write(&file_path, "hello earth").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(matches!(
&results[0].replace_result,
Some(ReplaceResult::Error(msg)) if msg.contains("File changed since search")
));
assert_file_content(&file_path, "hello earth");
}
#[test]
fn test_byte_mode_file_fully_truncated_single_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"rust",
)];
std::fs::write(&file_path, "hello").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(results[0].replace_result.is_none());
assert_file_content(&file_path, "hello");
}
#[test]
fn test_byte_mode_file_partially_truncated_single_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world hi world");
let mut results = vec![
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 6, 11, "world", "rust"),
create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
15,
20,
"world",
"blah",
),
];
std::fs::write(&file_path, "hello world hi wo").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(results[1].replace_result.is_none());
assert_file_content(&file_path, "hello rust hi wo");
}
#[test]
fn test_byte_mode_file_truncated_partial_match() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world test");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"rust",
)];
std::fs::write(&file_path, "hello wo").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(results[0].replace_result.is_none());
assert_file_content(&file_path, "hello wo");
}
#[test]
fn test_byte_mode_file_truncated_multiple_replacements() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz qux");
let mut results = vec![
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 12, 15, "qux", "DDD"),
];
std::fs::write(&file_path, "foo bar b").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(results[1].replace_result.is_none());
assert!(results[2].replace_result.is_none());
assert_file_content(&file_path, "AAA bar b");
}
#[test]
fn test_byte_mode_first_replacement_succeeds_second_content_changed() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "foo bar baz");
let mut results = vec![
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "foo", "AAA"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "baz", "CCC"),
];
std::fs::write(&file_path, "foo bar qux").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert!(matches!(
&results[1].replace_result,
Some(ReplaceResult::Error(msg)) if msg.contains("File changed since search")
));
assert_file_content(&file_path, "AAA bar qux");
}
#[test]
fn test_byte_mode_replacement_with_different_length() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"everyone",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello everyone");
}
#[test]
fn test_byte_mode_multiline_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
2,
2,
6,
12,
"line2\n",
"REPLACED\n",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "line1\nREPLACED\nline3\n");
}
#[test]
fn test_byte_mode_spanning_multiple_lines() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "line1\nline2\nline3\n");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
2,
0,
12,
"line1\nline2\n",
"REPLACED\n",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "REPLACED\nline3\n");
}
#[test]
fn test_byte_mode_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
0,
5,
"hello",
"world",
)];
std::fs::write(&file_path, "").unwrap();
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert!(results[0].replace_result.is_none());
assert_file_content(&file_path, "");
}
#[test]
fn test_byte_mode_preserves_trailing_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world and more");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"rust",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello rust and more");
}
#[test]
fn test_byte_mode_empty_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello ");
}
#[test]
fn test_byte_mode_unicode_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello 世界 test");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
12,
"世界",
"world",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello world test");
}
#[test]
fn test_byte_mode_unicode_replacement() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "hello world test");
let mut results = vec![create_byte_range_result(
file_path.to_str().unwrap(),
1,
1,
6,
11,
"world",
"世界",
)];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "hello 世界 test");
}
#[test]
fn test_byte_mode_multiple_unicode_replacements() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_file(&temp_dir, "test.txt", "aaa bbb ccc");
let mut results = vec![
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 0, 3, "aaa", "日"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 4, 7, "bbb", "本"),
create_byte_range_result(file_path.to_str().unwrap(), 1, 1, 8, 11, "ccc", "語"),
];
let result = replace_in_file(&mut results);
assert!(result.is_ok());
assert_eq!(results[0].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[1].replace_result, Some(ReplaceResult::Success));
assert_eq!(results[2].replace_result, Some(ReplaceResult::Success));
assert_file_content(&file_path, "日 本 語");
}
}
}