use crate::core::anchor;
use super::anchors::{AnchorMiss, AnchorOp};
pub(crate) enum ResolveError {
Conflict(Vec<AnchorMiss>),
Invalid(String),
}
#[derive(Clone, Debug)]
pub(crate) struct ResolvedEdit {
start_idx: usize,
remove_count: usize,
new_lines: Vec<String>,
lo: usize,
hi: usize,
}
pub(crate) fn split_lines(content: &str) -> (Vec<String>, &'static str, bool) {
let sep = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let trailing = content.ends_with('\n');
let lines = content.lines().map(String::from).collect();
(lines, sep, trailing)
}
pub(crate) fn join_lines(lines: &[String], sep: &str, trailing: bool) -> String {
let mut out = lines.join(sep);
if trailing && !lines.is_empty() {
out.push_str(sep);
}
out
}
fn split_new_text(s: &str) -> Vec<String> {
if s.is_empty() {
return Vec::new();
}
let trimmed = s
.strip_suffix("\r\n")
.or_else(|| s.strip_suffix('\n'))
.unwrap_or(s);
trimmed
.split('\n')
.map(|l| l.trim_end_matches('\r').to_string())
.collect()
}
pub(crate) fn resolve_ops(
lines: &[String],
ops: &[AnchorOp],
) -> Result<Vec<ResolvedEdit>, ResolveError> {
let len = lines.len();
let mut misses: Vec<AnchorMiss> = Vec::new();
let mut edits: Vec<ResolvedEdit> = Vec::new();
let check = |line: usize, hash: &str, misses: &mut Vec<AnchorMiss>| {
match lines.get(line - 1) {
Some(cur) if anchor::hash_matches(cur, hash) => {}
Some(cur) => misses.push(AnchorMiss {
line,
expected: hash.to_string(),
actual: anchor::line_hash(cur),
}),
None => misses.push(AnchorMiss {
line,
expected: hash.to_string(),
actual: "<eof>".to_string(),
}),
}
};
for op in ops {
match op {
AnchorOp::SetLine {
line,
hash,
new_text,
} => {
if *line > len {
return Err(ResolveError::Invalid(format!(
"line {line} is past end of file ({len} lines)"
)));
}
check(*line, hash, &mut misses);
edits.push(ResolvedEdit {
start_idx: line - 1,
remove_count: 1,
new_lines: split_new_text(new_text),
lo: *line,
hi: *line,
});
}
AnchorOp::ReplaceLines {
start_line,
start_hash,
end_line,
end_hash,
new_text,
} => {
if let Err(e) = check_range(*start_line, *end_line, len) {
return Err(ResolveError::Invalid(e));
}
check(*start_line, start_hash, &mut misses);
check(*end_line, end_hash, &mut misses);
edits.push(ResolvedEdit {
start_idx: start_line - 1,
remove_count: end_line - start_line + 1,
new_lines: split_new_text(new_text),
lo: *start_line,
hi: *end_line,
});
}
AnchorOp::Delete {
start_line,
start_hash,
end_line,
end_hash,
} => {
if let Err(e) = check_range(*start_line, *end_line, len) {
return Err(ResolveError::Invalid(e));
}
check(*start_line, start_hash, &mut misses);
check(*end_line, end_hash, &mut misses);
edits.push(ResolvedEdit {
start_idx: start_line - 1,
remove_count: end_line - start_line + 1,
new_lines: Vec::new(),
lo: *start_line,
hi: *end_line,
});
}
AnchorOp::Create { .. } => {
return Err(ResolveError::Invalid(
"create cannot be combined with anchored ops (new files have no preimage)"
.to_string(),
));
}
AnchorOp::InsertAfter {
line,
hash,
new_text,
} => {
if *line > len {
return Err(ResolveError::Invalid(format!(
"insert_after line {line} is past end of file ({len} lines); \
use line={len} to append"
)));
}
let new_lines = split_new_text(new_text);
if new_lines.is_empty() {
return Err(ResolveError::Invalid(
"insert_after needs non-empty new_text (use delete to remove lines)"
.to_string(),
));
}
if let Some(h) = hash {
check(*line, h, &mut misses);
}
edits.push(ResolvedEdit {
start_idx: *line,
remove_count: 0,
new_lines,
lo: *line,
hi: *line,
});
}
}
}
if !misses.is_empty() {
return Err(ResolveError::Conflict(misses));
}
if let Some(overlap) = first_overlap(&edits) {
return Err(ResolveError::Invalid(overlap));
}
Ok(edits)
}
fn check_range(start: usize, end: usize, len: usize) -> Result<(), String> {
if start > end {
return Err(format!("start_line {start} is after end_line {end}"));
}
if end > len {
return Err(format!("end_line {end} is past end of file ({len} lines)"));
}
Ok(())
}
fn first_overlap(edits: &[ResolvedEdit]) -> Option<String> {
for (i, a) in edits.iter().enumerate() {
for b in &edits[i + 1..] {
if a.lo <= b.hi && b.lo <= a.hi {
return Some(format!(
"overlapping edits: lines {}-{} and {}-{} touch the same region — \
split into separate calls or merge into one replace_lines",
a.lo, a.hi, b.lo, b.hi
));
}
}
}
None
}
#[must_use]
pub(crate) fn apply_edits(mut lines: Vec<String>, mut edits: Vec<ResolvedEdit>) -> Vec<String> {
edits.sort_by_key(|e| std::cmp::Reverse(e.start_idx));
for e in edits {
let end = e.start_idx + e.remove_count;
lines.splice(e.start_idx..end, e.new_lines);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn anc(_line: usize, content: &str) -> String {
anchor::line_hash(content)
}
#[test]
fn split_and_join_round_trip_lf() {
let content = "a\nb\nc\n";
let (lines, sep, trailing) = split_lines(content);
assert_eq!(lines, vec!["a", "b", "c"]);
assert_eq!(sep, "\n");
assert!(trailing);
assert_eq!(join_lines(&lines, sep, trailing), content);
}
#[test]
fn split_and_join_round_trip_no_trailing() {
let content = "a\nb";
let (lines, sep, trailing) = split_lines(content);
assert!(!trailing);
assert_eq!(join_lines(&lines, sep, trailing), content);
}
#[test]
fn split_and_join_round_trip_crlf() {
let content = "a\r\nb\r\n";
let (lines, sep, trailing) = split_lines(content);
assert_eq!(sep, "\r\n");
assert_eq!(join_lines(&lines, sep, trailing), content);
}
#[test]
fn split_new_text_strips_one_trailing_newline() {
assert_eq!(split_new_text("foo"), vec!["foo"]);
assert_eq!(split_new_text("foo\n"), vec!["foo"]);
assert_eq!(split_new_text("foo\nbar"), vec!["foo", "bar"]);
assert_eq!(split_new_text("foo\nbar\n"), vec!["foo", "bar"]);
assert_eq!(split_new_text(""), Vec::<String>::new());
assert_eq!(split_new_text("\n"), vec![""]);
}
#[test]
fn set_line_replaces_in_place() {
let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let ops = vec![AnchorOp::SetLine {
line: 2,
hash: anc(2, "b"),
new_text: "B".to_string(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
let out = apply_edits(lines, edits);
assert_eq!(out, vec!["a", "B", "c"]);
}
#[test]
fn set_line_empty_text_deletes() {
let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let ops = vec![AnchorOp::SetLine {
line: 2,
hash: anc(2, "b"),
new_text: String::new(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["a", "c"]);
}
#[test]
fn set_line_expands_to_multiple_lines() {
let lines = vec!["a".to_string(), "b".to_string()];
let ops = vec![AnchorOp::SetLine {
line: 1,
hash: anc(1, "a"),
new_text: "x\ny\nz".to_string(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["x", "y", "z", "b"]);
}
#[test]
fn replace_lines_collapses_range() {
let lines = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let ops = vec![AnchorOp::ReplaceLines {
start_line: 2,
start_hash: anc(2, "b"),
end_line: 3,
end_hash: anc(3, "c"),
new_text: "X".to_string(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["a", "X", "d"]);
}
#[test]
fn insert_after_line_zero_prepends() {
let lines = vec!["a".to_string(), "b".to_string()];
let ops = vec![AnchorOp::InsertAfter {
line: 0,
hash: None,
new_text: "// top".to_string(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["// top", "a", "b"]);
}
#[test]
fn insert_after_last_line_appends() {
let lines = vec!["a".to_string(), "b".to_string()];
let ops = vec![AnchorOp::InsertAfter {
line: 2,
hash: Some(anc(2, "b")),
new_text: "c".to_string(),
}];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["a", "b", "c"]);
}
#[test]
fn stale_anchor_is_reported_as_conflict() {
let lines = vec!["a".to_string(), "b".to_string()];
let ops = vec![AnchorOp::SetLine {
line: 2,
hash: "ffff".to_string(), new_text: "B".to_string(),
}];
match resolve_ops(&lines, &ops) {
Err(ResolveError::Conflict(misses)) => {
assert_eq!(misses.len(), 1);
assert_eq!(misses[0].line, 2);
assert_eq!(misses[0].actual, anchor::line_hash("b"));
}
_ => panic!("expected a Conflict for the stale anchor"),
}
}
#[test]
fn all_stale_anchors_collected() {
let lines = vec!["a".to_string(), "b".to_string()];
let ops = vec![
AnchorOp::SetLine {
line: 1,
hash: "0000".into(),
new_text: "A".into(),
},
AnchorOp::SetLine {
line: 2,
hash: "1111".into(),
new_text: "B".into(),
},
];
match resolve_ops(&lines, &ops) {
Err(ResolveError::Conflict(misses)) => assert_eq!(misses.len(), 2),
_ => panic!("expected both misses collected"),
}
}
#[test]
fn overlapping_edits_rejected() {
let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let ops = vec![
AnchorOp::SetLine {
line: 2,
hash: anc(2, "b"),
new_text: "B".into(),
},
AnchorOp::ReplaceLines {
start_line: 1,
start_hash: anc(1, "a"),
end_line: 2,
end_hash: anc(2, "b"),
new_text: "X".into(),
},
];
match resolve_ops(&lines, &ops) {
Err(ResolveError::Invalid(msg)) => assert!(msg.contains("overlapping")),
_ => panic!("expected overlap rejection"),
}
}
#[test]
fn out_of_range_line_rejected() {
let lines = vec!["a".to_string()];
let ops = vec![AnchorOp::SetLine {
line: 5,
hash: "aa".into(),
new_text: "x".into(),
}];
assert!(matches!(
resolve_ops(&lines, &ops),
Err(ResolveError::Invalid(_))
));
}
#[test]
fn op_input_order_does_not_change_result() {
let lines: Vec<String> = ["1", "2", "3", "4", "5"]
.iter()
.map(ToString::to_string)
.collect();
let a = AnchorOp::SetLine {
line: 1,
hash: anc(1, "1"),
new_text: "A\nA2".into(),
};
let b = AnchorOp::ReplaceLines {
start_line: 3,
start_hash: anc(3, "3"),
end_line: 4,
end_hash: anc(4, "4"),
new_text: "B".into(),
};
let c = AnchorOp::InsertAfter {
line: 5,
hash: Some(anc(5, "5")),
new_text: "C".into(),
};
let forward = apply_edits(
lines.clone(),
resolve_ops(&lines, &[a.clone(), b.clone(), c.clone()])
.ok()
.unwrap(),
);
let reversed = apply_edits(lines.clone(), resolve_ops(&lines, &[c, b, a]).ok().unwrap());
assert_eq!(forward, reversed);
assert_eq!(forward, vec!["A", "A2", "2", "B", "5", "C"]);
}
#[test]
fn batch_bottom_up_keeps_line_numbers_valid() {
let lines = vec![
"1".to_string(),
"2".to_string(),
"3".to_string(),
"4".to_string(),
"5".to_string(),
];
let ops = vec![
AnchorOp::ReplaceLines {
start_line: 1,
start_hash: anc(1, "1"),
end_line: 2,
end_hash: anc(2, "2"),
new_text: "A".into(), },
AnchorOp::SetLine {
line: 5,
hash: anc(5, "5"),
new_text: "E".into(),
},
];
let edits = resolve_ops(&lines, &ops).ok().unwrap();
assert_eq!(apply_edits(lines, edits), vec!["A", "3", "4", "E"]);
}
}