use crate::support::text::{self, truncate_with_ellipsis};
use crate::types::{ChangesInfo, FailChange};
use crate::{Error, Result};
const LINE_MARKER_SEARCH_START: &str = "<<<<<<< SEARCH";
const LINE_MARKER_SEP: &str = "=======";
const LINE_MARKER_REPLACE_END: &str = ">>>>>>> REPLACE";
pub fn apply_changes(original_content: impl Into<String>, changes: impl Into<String>) -> Result<(String, ChangesInfo)> {
let original_content = original_content.into();
let changes_str = changes.into();
let is_block_mode = changes_str.lines().any(|line| line == LINE_MARKER_SEARCH_START);
if !is_block_mode {
return Ok((
changes_str,
ChangesInfo {
changed_count: 1,
failed_changes: Vec::new(),
},
));
}
let requests = process_change_requests(&changes_str)?;
let mut current_content = original_content;
let mut changed_count = 0;
let mut failed_changes = Vec::new();
fn replace_first_remove_line(mut content: String, search_pattern: &str) -> (String, bool) {
if let Some(pos) = content.find(search_pattern) {
let mut start = pos;
let mut end = pos + search_pattern.len();
let len = content.len();
if end < len {
let tail = &content[end..];
if tail.starts_with("\r\n") {
end += 2;
} else if tail.starts_with('\n') {
end += 1;
}
} else if start > 0 {
let head = &content[..start];
if head.ends_with("\r\n") {
start -= 2;
} else if head.ends_with('\n') {
start -= 1;
}
}
if start < end {
content.replace_range(start..end, "");
return (content, true);
}
}
(content, false)
}
for req in requests {
let search_pattern = &changes_str[req.search_start_idx..req.search_end_idx];
let replace_pattern = &changes_str[req.replace_start_idx..req.replace_end_idx];
let (content, changed) = if replace_pattern.is_empty() && !search_pattern.is_empty() {
let (content, changed) = replace_first_remove_line(current_content, search_pattern);
if !changed && content.contains("\r\n") {
let content = content.replace("\r\n", "\n");
replace_first_remove_line(content, search_pattern)
} else {
(content, changed)
}
} else {
match text::replace_first(current_content, search_pattern, replace_pattern) {
(content, true) => (content, true),
(content, false) => {
if content.contains("\r\n") {
let content = content.replace("\r\n", "\n");
text::replace_first(content, search_pattern, replace_pattern)
} else {
(content, false)
}
}
}
};
if changed {
changed_count += 1;
} else {
failed_changes.push(FailChange {
search: search_pattern.to_string(),
replace: replace_pattern.to_string(),
reason: "Search block not found in content".to_string(),
});
}
current_content = content;
}
Ok((
current_content,
ChangesInfo {
changed_count,
failed_changes,
},
))
}
#[derive(Debug)] struct ChangeRequestIndices {
search_start_idx: usize,
search_end_idx: usize, replace_start_idx: usize,
replace_end_idx: usize, }
fn process_change_requests(changes_str: &str) -> Result<Vec<ChangeRequestIndices>> {
let mut requests = Vec::new();
enum ParseState {
ExpectBlockStartOrWhitespace,
InSearchPattern {
pattern_start_offset: usize,
},
InReplacePattern {
search_pattern_start_offset: usize,
search_pattern_end_offset: usize, replace_pattern_start_offset: usize,
},
}
let mut state = ParseState::ExpectBlockStartOrWhitespace;
let mut current_byte_offset = 0;
for line_str in changes_str.lines() {
let line_start_byte_offset = current_byte_offset;
current_byte_offset += line_str.len();
if current_byte_offset < changes_str.len() {
current_byte_offset += 1;
}
match state {
ParseState::ExpectBlockStartOrWhitespace => {
if line_str.trim().is_empty() {
} else if line_str == LINE_MARKER_SEARCH_START {
state = ParseState::InSearchPattern {
pattern_start_offset: current_byte_offset,
};
} else {
return Err(Error::custom(format!(
"Malformed changes: Expected '{LINE_MARKER_SEARCH_START}' or a whitespace line to start a block. Found text outside of a valid block structure. Line: '{}'",
truncate_with_ellipsis(line_str, 100, "...")
)));
}
}
ParseState::InSearchPattern { pattern_start_offset } => {
if line_str == LINE_MARKER_SEP {
let mut search_end = line_start_byte_offset;
if search_end > pattern_start_offset && changes_str.as_bytes().get(search_end - 1) == Some(&b'\n') {
search_end -= 1;
}
if search_end < pattern_start_offset {
search_end = pattern_start_offset;
}
state = ParseState::InReplacePattern {
search_pattern_start_offset: pattern_start_offset,
search_pattern_end_offset: search_end,
replace_pattern_start_offset: current_byte_offset,
};
} else {
}
}
ParseState::InReplacePattern {
search_pattern_start_offset,
search_pattern_end_offset,
replace_pattern_start_offset,
} => {
if line_str == LINE_MARKER_REPLACE_END {
let mut replace_end = line_start_byte_offset;
if replace_end > replace_pattern_start_offset
&& changes_str.as_bytes().get(replace_end - 1) == Some(&b'\n')
{
replace_end -= 1;
}
if replace_end < replace_pattern_start_offset {
replace_end = replace_pattern_start_offset;
}
requests.push(ChangeRequestIndices {
search_start_idx: search_pattern_start_offset,
search_end_idx: search_pattern_end_offset,
replace_start_idx: replace_pattern_start_offset,
replace_end_idx: replace_end,
});
state = ParseState::ExpectBlockStartOrWhitespace;
} else {
}
}
}
}
match state {
ParseState::ExpectBlockStartOrWhitespace => Ok(requests),
ParseState::InSearchPattern { .. } => Err(Error::custom(format!(
"Malformed change block: Ended in search pattern. Missing separator marker '{LINE_MARKER_SEP}'."
))),
ParseState::InReplacePattern { .. } => Err(Error::custom(format!(
"Malformed change block: Ended in replace pattern. Missing end marker '{LINE_MARKER_REPLACE_END}'."
))),
}
}
#[cfg(test)]
#[path = "change_tests.rs"]
mod tests;