use std::collections::HashMap;
pub fn diff_snapshots(old: &str, new: &str) -> String {
let old_by_uid = uid_line_map(old);
let new_by_uid = uid_line_map(new);
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
let mut unchanged: usize = 0;
for (&uid, old_line) in &old_by_uid {
match new_by_uid.get(uid) {
None => removed.push(format!("- {old_line}")),
Some(new_line) => {
if old_line == new_line {
unchanged += 1;
} else {
changed.push(format!("~ {old_line} -> {new_line}"));
}
}
}
}
for (&uid, new_line) in &new_by_uid {
if !old_by_uid.contains_key(uid) {
added.push(format!("+ {new_line}"));
}
}
let has_changes = !added.is_empty() || !removed.is_empty() || !changed.is_empty();
let mut out = String::new();
for line in &added {
out.push_str(line);
out.push('\n');
}
for line in &removed {
out.push_str(line);
out.push('\n');
}
for line in &changed {
out.push_str(line);
out.push('\n');
}
if unchanged > 0 {
out.push_str(&format!("= {unchanged} unchanged elements\n"));
}
if !has_changes {
out.push_str("No changes detected.\n");
}
out
}
pub struct DiffStats {
pub added: usize,
pub removed: usize,
pub changed: usize,
}
pub fn diff_stats(diff: &str) -> DiffStats {
let mut added = 0usize;
let mut removed = 0usize;
let mut changed = 0usize;
for line in diff.lines() {
if line.starts_with("+ ") {
added += 1;
} else if line.starts_with("- ") {
removed += 1;
} else if line.starts_with("~ ") {
changed += 1;
}
}
DiffStats { added, removed, changed }
}
fn uid_line_map(text: &str) -> HashMap<&str, &str> {
let mut map = HashMap::new();
for line in text.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("uid=") {
if let Some(space_idx) = rest.find(' ') {
let uid = &rest[..space_idx];
map.insert(uid, trimmed);
} else {
map.insert(rest, trimmed);
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_changes() {
let snap = "uid=n1 heading \"Hello\"\nuid=n2 button \"OK\"\n";
let result = diff_snapshots(snap, snap);
assert!(result.contains("No changes"));
}
#[test]
fn added_element() {
let old = "uid=n1 heading \"Hello\"\n";
let new = "uid=n1 heading \"Hello\"\nuid=n2 button \"OK\"\n";
let result = diff_snapshots(old, new);
assert!(result.contains("+ uid=n2 button \"OK\""));
assert!(result.contains("= 1 unchanged"));
let stats = diff_stats(&result);
assert_eq!(stats.added, 1);
assert_eq!(stats.removed, 0);
assert_eq!(stats.changed, 0);
}
#[test]
fn removed_element() {
let old = "uid=n1 heading \"Hello\"\nuid=n2 button \"OK\"\n";
let new = "uid=n1 heading \"Hello\"\n";
let result = diff_snapshots(old, new);
assert!(result.contains("- uid=n2 button \"OK\""));
let stats = diff_stats(&result);
assert_eq!(stats.removed, 1);
}
#[test]
fn changed_element() {
let old = "uid=n1 textbox value=\"\"\n";
let new = "uid=n1 textbox value=\"hello\"\n";
let result = diff_snapshots(old, new);
assert!(result.contains("~ uid=n1 textbox"));
let stats = diff_stats(&result);
assert_eq!(stats.changed, 1);
}
#[test]
fn mixed_changes() {
let old = "uid=n1 heading \"Title\"\nuid=n2 button \"Submit\"\nuid=n3 textbox value=\"\"\n";
let new = "uid=n1 heading \"Title\"\nuid=n3 textbox value=\"done\"\nuid=n4 heading \"Success\"\n";
let result = diff_snapshots(old, new);
assert!(result.contains("+ uid=n4"));
assert!(result.contains("- uid=n2"));
assert!(result.contains("~ uid=n3"));
assert!(result.contains("= 1 unchanged"));
}
#[test]
fn indented_lines() {
let old = " uid=n1 heading \"Hello\"\n uid=n2 button \"OK\"\n";
let new = " uid=n1 heading \"Hello\"\n uid=n3 link \"New\"\n";
let result = diff_snapshots(old, new);
assert!(result.contains("+ uid=n3"));
assert!(result.contains("- uid=n2"));
}
}