use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use diff::Result as DiffResult;
use diff::lines as diff_lines;
use serde::Serialize;
use sha2::{Digest, Sha256};
use termcolor::{Buffer, Color, ColorSpec, WriteColor};
#[derive(Debug, Clone, Serialize)]
pub struct FileDiff {
pub path: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head_hash: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SnapshotDiffResult {
pub identical: usize,
pub modified: usize,
pub added: usize,
pub removed: usize,
pub total: usize,
pub files: Vec<FileDiff>,
}
impl SnapshotDiffResult {
pub fn new(files: Vec<FileDiff>) -> Self {
let mut identical = 0usize;
let mut modified = 0usize;
let mut added = 0usize;
let mut removed = 0usize;
for f in &files {
match f.status.as_str() {
"identical" => identical += 1,
"modified" => modified += 1,
"added" => added += 1,
"removed" => removed += 1,
_ => {}
}
}
Self {
identical,
modified,
added,
removed,
total: files.len(),
files,
}
}
fn changes(&self) -> Vec<&FileDiff> {
self.files
.iter()
.filter(|f| f.status != "identical")
.collect()
}
pub fn render_terminal(&self) -> Buffer {
let mut buf = Buffer::ansi();
buf.set_color(ColorSpec::new().set_bold(true)).ok();
buf.write_all(b"Snapshot Diff Summary\n").ok();
buf.reset().ok();
buf.set_color(ColorSpec::new().set_bold(true)).ok();
buf.write_all(b" ").ok();
buf.reset().ok();
buf.set_color(ColorSpec::new().set_fg(Some(Color::Green)))
.ok();
buf.write_all(format!("Identical: {}\n", self.identical).as_bytes())
.ok();
buf.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))
.ok();
buf.write_all(format!("Modified: {}\n", self.modified).as_bytes())
.ok();
buf.set_color(ColorSpec::new().set_fg(Some(Color::Blue)))
.ok();
buf.write_all(format!("Added: {}\n", self.added).as_bytes())
.ok();
buf.set_color(ColorSpec::new().set_fg(Some(Color::Red)))
.ok();
buf.write_all(format!("Removed: {}\n", self.removed).as_bytes())
.ok();
buf.set_color(ColorSpec::new().set_bold(true)).ok();
buf.write_all(b" ").ok();
buf.reset().ok();
buf.write_all(format!("Total: {}\n", self.total).as_bytes())
.ok();
buf.reset().ok();
let changes = self.changes();
if !changes.is_empty() {
buf.set_color(ColorSpec::new().set_bold(true)).ok();
buf.write_all(b"\nDifferences\n").ok();
buf.reset().ok();
for file_diff in &changes {
let status_color = match file_diff.status.as_str() {
"modified" => Color::Yellow,
"added" => Color::Blue,
"removed" => Color::Red,
_ => Color::White,
};
buf.set_color(ColorSpec::new().set_bold(true)).ok();
buf.write_all(b"\n").ok();
buf.set_color(ColorSpec::new().set_fg(Some(status_color)))
.ok();
buf.write_all(
format!(" {} {}\n", file_diff.status.to_uppercase(), file_diff.path)
.as_bytes(),
)
.ok();
buf.reset().ok();
if let Some(diff_text) = &file_diff.diff {
for line in diff_text.lines() {
if line.starts_with("---") {
buf.set_color(ColorSpec::new().set_fg(Some(Color::Red)))
.ok();
} else if line.starts_with("+++") {
buf.set_color(ColorSpec::new().set_fg(Some(Color::Green)))
.ok();
} else if line.starts_with("@@") {
buf.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))
.ok();
} else if line.starts_with("+") && !line.starts_with("+++") {
buf.set_color(ColorSpec::new().set_fg(Some(Color::Green)))
.ok();
} else if line.starts_with("-") && !line.starts_with("---") {
buf.set_color(ColorSpec::new().set_fg(Some(Color::Red)))
.ok();
} else {
buf.reset().ok();
}
buf.write_all(format!(" {}\n", line).as_bytes()).ok();
buf.reset().ok();
}
}
}
}
buf
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("serialize SnapshotDiffResult")
}
}
fn collect_files(dir: &Path) -> BTreeSet<PathBuf> {
let mut files = BTreeSet::new();
if !dir.exists() {
return files;
}
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file()
&& let Ok(rel) = path.strip_prefix(dir)
{
files.insert(rel.to_path_buf());
}
}
files
}
fn file_hash(path: &Path) -> String {
let contents = std::fs::read(path).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(&contents);
format!("{:x}", hasher.finalize())
}
fn text_diff(old: &str, new: &str) -> String {
let diff_result = diff_lines(old, new);
if diff_result.is_empty() {
return String::new();
}
let has_diff = diff_result
.iter()
.any(|r| !matches!(r, DiffResult::Both(_, _)));
if !has_diff {
return String::new();
}
let mut output = String::new();
output.push_str("--- base\n");
output.push_str("+++ head\n");
let lines: Vec<DiffResult<&str>> = diff_result;
let context = 3;
let mut change_positions: Vec<usize> = Vec::new();
for (i, diff) in lines.iter().enumerate() {
if !matches!(diff, DiffResult::Both(_, _)) {
change_positions.push(i);
}
}
let mut hunk_ranges: Vec<(usize, usize)> = Vec::new();
if !change_positions.is_empty() {
let mut range_start = change_positions[0];
let mut range_end = change_positions[0];
for i in 1..change_positions.len() {
let prev = change_positions[i - 1];
let curr = change_positions[i];
let equal_between = curr - prev - 1;
if equal_between > context {
hunk_ranges.push((range_start, range_end));
range_start = curr;
range_end = curr;
} else {
range_end = curr;
}
}
hunk_ranges.push((range_start, range_end));
}
for (start, end) in &hunk_ranges {
let mut hunk_start = *start;
let mut equal_count = 0usize;
for i in (0..*start).rev() {
if matches!(lines[i], DiffResult::Both(_, _)) {
equal_count += 1;
if equal_count >= context {
break;
}
hunk_start = i;
} else {
break;
}
}
let mut hunk_end = *end;
equal_count = 0usize;
for (i, line) in lines.iter().enumerate().skip(*end + 1) {
if matches!(line, DiffResult::Both(_, _)) {
equal_count += 1;
if equal_count >= context {
break;
}
hunk_end = i;
} else {
break;
}
}
let mut old_line = 1usize;
let mut new_line = 1usize;
for line in lines.iter().take(hunk_start) {
match line {
DiffResult::Both(_, s) | DiffResult::Right(s) => {
new_line += s.lines().count();
}
DiffResult::Left(s) => {
old_line += s.lines().count();
}
}
}
let mut old_count = 0usize;
let mut new_count = 0usize;
for line in lines.iter().take(hunk_end + 1).skip(hunk_start) {
match line {
DiffResult::Both(s, _) => {
old_count += s.lines().count();
new_count += s.lines().count();
}
DiffResult::Left(s) => {
old_count += s.lines().count();
}
DiffResult::Right(s) => {
new_count += s.lines().count();
}
}
}
output.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
old_line, old_count, new_line, new_count
));
for line in lines.iter().take(hunk_end + 1).skip(hunk_start) {
match line {
DiffResult::Both(s, _) => {
for line in s.lines() {
output.push_str(&format!(" {}\n", line));
}
}
DiffResult::Left(s) => {
for line in s.lines() {
output.push_str(&format!("-{}\n", line));
}
}
DiffResult::Right(s) => {
for line in s.lines() {
output.push_str(&format!("+{}\n", line));
}
}
}
}
output.push('\n');
}
output.trim_end_matches('\n').to_string()
}
pub fn compare_snapshots(base: &Path, head: &Path) -> SnapshotDiffResult {
let base_files = collect_files(base);
let head_files = collect_files(head);
let all_paths: BTreeSet<PathBuf> = base_files.union(&head_files).cloned().collect();
let mut file_diffs = Vec::new();
for rel_path in &all_paths {
let base_path = base.join(rel_path);
let head_path = head.join(rel_path);
let in_base = base_files.contains(rel_path);
let in_head = head_files.contains(rel_path);
let (status, diff_text, base_hash, head_hash) = match (in_base, in_head) {
(true, true) => {
let base_content = std::fs::read_to_string(&base_path).unwrap_or_default();
let head_content = std::fs::read_to_string(&head_path).unwrap_or_default();
let base_hash = file_hash(&base_path);
let head_hash = file_hash(&head_path);
if base_hash == head_hash {
(
"identical".to_string(),
None,
Some(base_hash),
Some(head_hash),
)
} else {
if rel_path.extension().is_some_and(|ext| ext == "wasm") {
(
"modified".to_string(),
None,
Some(base_hash),
Some(head_hash),
)
} else {
let diff_text = text_diff(&base_content, &head_content);
(
"modified".to_string(),
Some(diff_text),
Some(base_hash),
Some(head_hash),
)
}
}
}
(false, true) => {
let head_hash = file_hash(&head_path);
("added".to_string(), None, None, Some(head_hash))
}
(true, false) => {
let base_hash = file_hash(&base_path);
("removed".to_string(), None, Some(base_hash), None)
}
(false, false) => unreachable!(),
};
file_diffs.push(FileDiff {
path: rel_path.to_string_lossy().to_string(),
status,
diff: diff_text,
base_hash,
head_hash,
});
}
SnapshotDiffResult::new(file_diffs)
}