#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub tag: char,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnifiedDiff {
pub hunks: Vec<Vec<DiffLine>>,
pub added: usize,
pub removed: usize,
}
pub fn unified_diff(old: &str, new: &str, context: usize) -> UnifiedDiff {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let (n, m) = (old_lines.len(), new_lines.len());
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if old_lines[i] == new_lines[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
#[derive(PartialEq)]
enum Op {
Keep,
Remove,
Add,
}
let mut ops: Vec<Op> = Vec::new();
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if old_lines[i] == new_lines[j] {
ops.push(Op::Keep);
i += 1;
j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
ops.push(Op::Remove);
i += 1;
} else {
ops.push(Op::Add);
j += 1;
}
}
while i < n {
ops.push(Op::Remove);
i += 1;
}
while j < m {
ops.push(Op::Add);
j += 1;
}
let mut tagged: Vec<DiffLine> = Vec::new();
let (mut oi, mut ni) = (0usize, 0usize);
let (mut added, mut removed) = (0usize, 0usize);
for op in &ops {
match op {
Op::Keep => {
tagged.push(DiffLine {
tag: ' ',
text: old_lines[oi].to_string(),
});
oi += 1;
ni += 1;
}
Op::Remove => {
tagged.push(DiffLine {
tag: '-',
text: old_lines[oi].to_string(),
});
removed += 1;
oi += 1;
}
Op::Add => {
tagged.push(DiffLine {
tag: '+',
text: new_lines[ni].to_string(),
});
added += 1;
ni += 1;
}
}
}
let hunks = group_hunks(&tagged, context);
UnifiedDiff {
hunks,
added,
removed,
}
}
pub fn render_unified(diff: &UnifiedDiff, old_label: &str, new_label: &str) -> String {
let mut out = String::new();
out.push_str(&format!("--- {old_label}\n+++ {new_label}\n"));
for (i, hunk) in diff.hunks.iter().enumerate() {
out.push_str(&format!("@@ hunk {} @@\n", i + 1));
for line in hunk {
out.push(line.tag);
out.push(' ');
out.push_str(&line.text);
out.push('\n');
}
}
out
}
fn group_hunks(tagged: &[DiffLine], context: usize) -> Vec<Vec<DiffLine>> {
let is_changed = |l: &DiffLine| l.tag == '+' || l.tag == '-';
let mut hunks: Vec<Vec<DiffLine>> = Vec::new();
let mut i = 0usize;
let n = tagged.len();
while i < n {
if !is_changed(&tagged[i]) {
i += 1;
continue;
}
let start = i.saturating_sub(context);
let mut end = i;
while end < n && is_changed(&tagged[end]) {
end += 1;
}
let mut trailing = end;
let mut count = 0;
while trailing < n && count < context {
if is_changed(&tagged[trailing]) {
break;
}
trailing += 1;
count += 1;
}
let hunk: Vec<DiffLine> = tagged[start..trailing].to_vec();
if !hunk.is_empty() {
hunks.push(hunk);
}
i = trailing.max(end).max(i + 1);
}
hunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_strings_produce_no_changes() {
let d = unified_diff("a\nb\nc", "a\nb\nc", 0);
assert_eq!(d.added, 0);
assert_eq!(d.removed, 0);
assert!(d.hunks.is_empty());
}
#[test]
fn single_line_change_is_detected() {
let d = unified_diff("a\nb\nc", "a\nx\nc", 0);
assert_eq!(d.added, 1);
assert_eq!(d.removed, 1);
let hunk = &d.hunks[0];
assert!(hunk.iter().any(|l| l.tag == '-' && l.text == "b"));
assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "x"));
}
#[test]
fn context_lines_are_included() {
let d = unified_diff("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", 1);
let hunk = &d.hunks[0];
assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "b"));
assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "d"));
assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "X"));
}
#[test]
fn render_unified_has_header_and_hunks() {
let d = unified_diff("a\nb", "a\nc", 0);
let s = render_unified(&d, "old.yaml", "new.yaml");
assert!(s.contains("--- old.yaml"));
assert!(s.contains("+++ new.yaml"));
assert!(s.contains("- b"));
assert!(s.contains("+ c"));
}
}