use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Change {
Equal,
Delete,
Insert,
}
impl Change {
#[inline]
#[must_use]
pub const fn marker(self) -> char {
match self {
Change::Equal => ' ',
Change::Delete => '-',
Change::Insert => '+',
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct Diff {
changes: Vec<(Change, String)>,
}
impl Diff {
pub fn lines(expected: &str, actual: &str) -> Self {
let old: Vec<&str> = split_lines(expected);
let new: Vec<&str> = split_lines(actual);
let mut changes = Vec::new();
let mut start = 0;
let max_prefix = old.len().min(new.len());
while start < max_prefix && old[start] == new[start] {
start += 1;
}
let mut old_end = old.len();
let mut new_end = new.len();
while old_end > start && new_end > start && old[old_end - 1] == new[new_end - 1] {
old_end -= 1;
new_end -= 1;
}
for line in &old[..start] {
changes.push((Change::Equal, String::from(*line)));
}
diff_middle(&old[start..old_end], &new[start..new_end], &mut changes);
for line in &old[old_end..] {
changes.push((Change::Equal, String::from(*line)));
}
Diff { changes }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.iter().all(|(c, _)| *c == Change::Equal)
}
pub fn changes(&self) -> impl Iterator<Item = (Change, &str)> {
self.changes.iter().map(|(c, s)| (*c, s.as_str()))
}
}
impl fmt::Display for Diff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, (change, text)) in self.changes.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
f.write_fmt(format_args!("{}{}", change.marker(), text))?;
}
Ok(())
}
}
fn split_lines(text: &str) -> Vec<&str> {
if text.is_empty() {
Vec::new()
} else {
text.split('\n').collect()
}
}
fn diff_middle<'a>(old: &[&'a str], new: &[&'a str], out: &mut Vec<(Change, String)>) {
if old.is_empty() {
out.extend(new.iter().map(|l| (Change::Insert, String::from(*l))));
return;
}
if new.is_empty() {
out.extend(old.iter().map(|l| (Change::Delete, String::from(*l))));
return;
}
let table = lcs_table(old, new);
let mut i = 0;
let mut j = 0;
while i < old.len() && j < new.len() {
if old[i] == new[j] {
out.push((Change::Equal, String::from(old[i])));
i += 1;
j += 1;
} else if table[i + 1][j] >= table[i][j + 1] {
out.push((Change::Delete, String::from(old[i])));
i += 1;
} else {
out.push((Change::Insert, String::from(new[j])));
j += 1;
}
}
out.extend(old[i..].iter().map(|l| (Change::Delete, String::from(*l))));
out.extend(new[j..].iter().map(|l| (Change::Insert, String::from(*l))));
}
fn lcs_table(old: &[&str], new: &[&str]) -> Vec<Vec<u32>> {
let rows = old.len() + 1;
let cols = new.len() + 1;
let mut table = alloc::vec![alloc::vec![0u32; cols]; rows];
for i in (0..old.len()).rev() {
for j in (0..new.len()).rev() {
table[i][j] = if old[i] == new[j] {
table[i + 1][j + 1] + 1
} else {
table[i + 1][j].max(table[i][j + 1])
};
}
}
table
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use alloc::string::ToString;
use alloc::vec;
#[test]
fn test_diff_identical_is_empty() {
assert!(Diff::lines("a\nb\nc", "a\nb\nc").is_empty());
}
#[test]
fn test_diff_empty_inputs_is_empty() {
assert!(Diff::lines("", "").is_empty());
}
#[test]
fn test_diff_single_line_change_reports_both_sides() {
let diff = Diff::lines("a\nb\nc", "a\nB\nc");
let changes: Vec<_> = diff.changes().collect();
assert_eq!(
changes,
vec![
(Change::Equal, "a"),
(Change::Delete, "b"),
(Change::Insert, "B"),
(Change::Equal, "c"),
]
);
}
#[test]
fn test_diff_insertion_at_end() {
let diff = Diff::lines("a\nb", "a\nb\nc");
let inserted: Vec<_> = diff
.changes()
.filter(|(c, _)| *c == Change::Insert)
.map(|(_, l)| l)
.collect();
assert_eq!(inserted, vec!["c"]);
}
#[test]
fn test_diff_deletion_at_start() {
let diff = Diff::lines("a\nb\nc", "b\nc");
let deleted: Vec<_> = diff
.changes()
.filter(|(c, _)| *c == Change::Delete)
.map(|(_, l)| l)
.collect();
assert_eq!(deleted, vec!["a"]);
}
#[test]
fn test_diff_all_different() {
let diff = Diff::lines("a\nb", "x\ny");
assert!(!diff.is_empty());
assert_eq!(
diff.changes().filter(|(c, _)| *c == Change::Delete).count(),
2
);
assert_eq!(
diff.changes().filter(|(c, _)| *c == Change::Insert).count(),
2
);
}
#[test]
fn test_diff_expected_empty_all_insert() {
let diff = Diff::lines("", "a\nb");
let changes: Vec<_> = diff.changes().collect();
assert_eq!(changes, vec![(Change::Insert, "a"), (Change::Insert, "b")]);
}
#[test]
fn test_diff_actual_empty_all_delete() {
let diff = Diff::lines("a\nb", "");
let changes: Vec<_> = diff.changes().collect();
assert_eq!(changes, vec![(Change::Delete, "a"), (Change::Delete, "b")]);
}
#[test]
fn test_diff_display_uses_markers() {
let diff = Diff::lines("a\nb", "a\nc");
let rendered = diff.to_string();
assert_eq!(rendered, " a\n-b\n+c");
}
#[test]
fn test_change_marker() {
assert_eq!(Change::Equal.marker(), ' ');
assert_eq!(Change::Delete.marker(), '-');
assert_eq!(Change::Insert.marker(), '+');
}
#[test]
fn test_diff_preserves_common_prefix_and_suffix() {
let diff = Diff::lines("head\nold\ntail", "head\nnew\ntail");
let changes: Vec<_> = diff.changes().collect();
assert_eq!(
changes,
vec![
(Change::Equal, "head"),
(Change::Delete, "old"),
(Change::Insert, "new"),
(Change::Equal, "tail"),
]
);
}
#[test]
fn test_diff_lcs_finds_shared_middle() {
let diff = Diff::lines("a\nkeep\nb", "x\nkeep\ny");
assert_eq!(
diff.changes().filter(|(c, _)| *c == Change::Equal).count(),
1
);
}
}