use similar::{ChangeTag, TextDiff};
use super::html_escape;
pub struct DiffOutput {
pub html: String,
pub added: usize,
pub removed: usize,
}
pub fn render_unified_diff(old: &str, new: &str) -> DiffOutput {
let diff = TextDiff::from_lines(old, new);
let changes: Vec<_> = diff.iter_all_changes().collect();
let mut html = String::from(r#"<div class="diff">"#);
let mut added = 0usize;
let mut removed = 0usize;
let mut i = 0;
while i < changes.len() {
let change = &changes[i];
match change.tag() {
ChangeTag::Equal => {
let escaped = html_escape(change.value());
let old_num = fmt_num(change.old_index());
let new_num = fmt_num(change.new_index());
html.push_str(&format!(
r#"<div class="diff-line diff-line--ctx"><span class="ln ln-old">{old_num}</span><span class="ln ln-new">{new_num}</span> {escaped}</div>"#
));
i += 1;
}
ChangeTag::Delete => {
removed += 1;
if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
added += 1;
let add_change = &changes[i + 1];
let old_num = fmt_num(change.old_index());
let new_num = fmt_num(add_change.new_index());
let del_val = strip_newline(change.value());
let add_val = strip_newline(add_change.value());
let (del_body, add_body) = intra_line_diff(del_val, add_val);
html.push_str(&format!(
r#"<div class="diff-line diff-line--del"><span class="ln ln-old">{old_num}</span><span class="ln ln-new"></span>-{del_body}</div>"#
));
html.push_str(&format!(
r#"<div class="diff-line diff-line--add"><span class="ln ln-old"></span><span class="ln ln-new">{new_num}</span>+{add_body}</div>"#
));
i += 2;
} else {
let escaped = html_escape(change.value());
let old_num = fmt_num(change.old_index());
html.push_str(&format!(
r#"<div class="diff-line diff-line--del"><span class="ln ln-old">{old_num}</span><span class="ln ln-new"></span>-{escaped}</div>"#
));
i += 1;
}
}
ChangeTag::Insert => {
added += 1;
let escaped = html_escape(change.value());
let new_num = fmt_num(change.new_index());
html.push_str(&format!(
r#"<div class="diff-line diff-line--add"><span class="ln ln-old"></span><span class="ln ln-new">{new_num}</span>+{escaped}</div>"#
));
i += 1;
}
}
}
html.push_str("</div>");
DiffOutput {
html,
added,
removed,
}
}
pub fn render_change_summary(added: usize, removed: usize) -> String {
let a_word = if added == 1 { "line" } else { "lines" };
let r_word = if removed == 1 { "line" } else { "lines" };
format!(
r#"<div class="diff-summary"><span class="diff-count--add">+{added} {a_word}</span> · <span class="diff-count--del">−{removed} {r_word}</span></div>"#
)
}
fn intra_line_diff(del_line: &str, add_line: &str) -> (String, String) {
let word_diff = TextDiff::from_words(del_line, add_line);
let mut del_html = String::new();
let mut add_html = String::new();
for change in word_diff.iter_all_changes() {
let escaped = html_escape(change.value());
match change.tag() {
ChangeTag::Equal => {
del_html.push_str(&escaped);
add_html.push_str(&escaped);
}
ChangeTag::Delete => {
del_html.push_str(&format!(r#"<span class="diff-tok--del">{escaped}</span>"#));
}
ChangeTag::Insert => {
add_html.push_str(&format!(r#"<span class="diff-tok--add">{escaped}</span>"#));
}
}
}
(del_html, add_html)
}
fn strip_newline(s: &str) -> &str {
s.trim_end_matches('\n').trim_end_matches('\r')
}
fn fmt_num(idx: Option<usize>) -> String {
idx.map_or(String::new(), |n| (n + 1).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_old_new_produces_zero_changes() {
let old = "hello\nworld\n";
let new = "hello\nworld\n";
let out = render_unified_diff(old, new);
assert_eq!(out.added, 0);
assert_eq!(out.removed, 0);
assert!(!out.html.contains("diff-line--add"));
assert!(!out.html.contains("diff-line--del"));
assert!(out.html.contains("diff-line--ctx"));
assert!(out.html.contains(r#"class="ln ln-old">1</span>"#));
assert!(out.html.contains(r#"class="ln ln-new">1</span>"#));
}
#[test]
fn pure_addition_produces_only_add_rows() {
let old = "";
let new = "line one\nline two\n";
let out = render_unified_diff(old, new);
assert_eq!(out.added, 2);
assert_eq!(out.removed, 0);
assert!(out.html.contains("diff-line--add"));
assert!(!out.html.contains("diff-line--del"));
assert!(out.html.contains("+line one"));
assert!(out.html.contains("+line two"));
assert!(out.html.contains(r#"class="ln ln-old"></span>"#));
assert!(out.html.contains(r#"class="ln ln-new">1</span>"#));
}
#[test]
fn pure_deletion_produces_only_del_rows() {
let old = "remove me\nand me\n";
let new = "";
let out = render_unified_diff(old, new);
assert_eq!(out.added, 0);
assert_eq!(out.removed, 2);
assert!(!out.html.contains("diff-line--add"));
assert!(out.html.contains("diff-line--del"));
assert!(out.html.contains("-remove me"));
assert!(out.html.contains("-and me"));
assert!(out.html.contains(r#"class="ln ln-old">1</span>"#));
assert!(out.html.contains(r#"class="ln ln-old">2</span>"#));
}
#[test]
fn mixed_change_counts_match() {
let old = "keep\nremove\nkeep\n";
let new = "keep\nkeep\nadded\n";
let out = render_unified_diff(old, new);
assert_eq!(out.added, 1);
assert_eq!(out.removed, 1);
assert!(out.html.contains("diff-line--add"));
assert!(out.html.contains("diff-line--del"));
assert!(out.html.contains("diff-line--ctx"));
}
#[test]
fn html_special_chars_are_escaped() {
let old = "<script>alert('xss')</script>\n";
let new = "<div>safe</div>\n";
let out = render_unified_diff(old, new);
assert!(!out.html.contains("<script>"));
assert!(out.html.contains("<script>"));
assert!(!out.html.contains("<div>"));
assert!(out.html.contains("<div>"));
let out2 = render_unified_diff("a & b\n", "a && b\n");
assert!(out2.html.contains("&"));
}
#[test]
fn diff_output_html_contains_expected_structure() {
let out = render_unified_diff("a\n", "b\n");
assert!(out.html.starts_with(r#"<div class="diff">"#));
assert!(out.html.ends_with("</div>"));
assert!(out.html.contains(r#"class="ln ln-old">1</span>"#));
assert!(out.html.contains(r#"class="ln ln-new">1</span>"#));
}
#[test]
fn paired_del_add_produces_token_spans() {
let old = "hello world\n";
let new = "hello earth\n";
let out = render_unified_diff(old, new);
assert!(out.html.contains("diff-tok--del"), "del line should have token highlight span");
assert!(out.html.contains("diff-tok--add"), "add line should have token highlight span");
assert_eq!(out.added, 1);
assert_eq!(out.removed, 1);
}
#[test]
fn unpaired_del_has_no_token_spans() {
let old = "line1\nline2\n";
let new = "line3\n";
let out = render_unified_diff(old, new);
assert!(out.html.contains("diff-line--del"));
assert_eq!(out.removed, 2);
assert_eq!(out.added, 1);
}
#[test]
fn change_summary_new_format() {
let s = render_change_summary(1, 0);
assert!(s.contains("+1 line"), "added count should appear: {s}");
assert!(s.contains("0 lines"), "removed count should appear: {s}");
assert!(s.contains("diff-count--add"), "should have add class: {s}");
assert!(s.contains("diff-count--del"), "should have del class: {s}");
}
#[test]
fn change_summary_pluralization() {
let s = render_change_summary(3, 2);
assert!(s.contains("+3 lines"), "added plural: {s}");
assert!(s.contains("2 lines"), "removed plural: {s}");
let s1 = render_change_summary(0, 1);
assert!(s1.contains("1 line"), "removed singular: {s1}");
}
}