use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::diff::{FileDiff, FileStatus, Hunk, Line};
use crate::ModelError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RangeSnapshots {
pub blobs: BTreeMap<String, Option<String>>,
pub boundaries: Vec<Boundary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Boundary {
pub sha: String,
pub files: BTreeMap<String, String>,
}
pub fn range_diff(snap: &RangeSnapshots, from: usize, to: usize, context: usize) -> Result<Vec<FileDiff>, ModelError> {
if from >= to || to >= snap.boundaries.len() {
return Err(ModelError::InvalidRange(format!(
"from {from} / to {to} against {} boundaries",
snap.boundaries.len()
)));
}
let lo = &snap.boundaries[from];
let hi = &snap.boundaries[to];
let mut paths: BTreeSet<&String> = lo.files.keys().collect();
paths.extend(hi.files.keys());
let content = |id: &String| -> Result<&Option<String>, ModelError> {
snap.blobs.get(id).ok_or_else(|| ModelError::InvalidRange(format!("blob {id} missing from the snapshot store")))
};
let mut files = Vec::new();
for path in paths {
let old_id = lo.files.get(path);
let new_id = hi.files.get(path);
if old_id == new_id {
continue; }
let status = match (old_id, new_id) {
(None, Some(_)) => FileStatus::Added,
(Some(_), None) => FileStatus::Deleted,
_ => FileStatus::Modified,
};
let old_blob = old_id.map(content).transpose()?;
let new_blob = new_id.map(content).transpose()?;
let old_path = old_id.map(|_| path.clone());
let new_path = new_id.map(|_| path.clone());
if matches!(old_blob, Some(None)) || matches!(new_blob, Some(None)) {
files.push(FileDiff {
old_path,
new_path,
status,
binary: true,
hunks: Vec::new(),
additions: 0,
deletions: 0,
notes: Vec::new(),
});
continue;
}
let old_text = old_blob.and_then(|b| b.as_deref()).unwrap_or("");
let new_text = new_blob.and_then(|b| b.as_deref()).unwrap_or("");
let hunks = diff_lines(old_text, new_text, context);
let additions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Add { .. })).count() as u32;
let deletions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Del { .. })).count() as u32;
if hunks.is_empty() {
continue; }
files.push(FileDiff { old_path, new_path, status, binary: false, hunks, additions, deletions, notes: Vec::new() });
}
Ok(files)
}
pub fn diff_lines(old: &str, new: &str, context: usize) -> Vec<Hunk> {
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let edits = myers_edits(&a, &b);
hunks_from(&records(&a, &b, &edits), context)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Edit {
Keep,
Del,
Add,
}
fn myers_edits(a: &[&str], b: &[&str]) -> Vec<Edit> {
let n = a.len() as isize;
let m = b.len() as isize;
let max = n + m;
if max == 0 {
return Vec::new();
}
let offset = max;
let idx = |k: isize| (k + offset) as usize;
let mut v = vec![0isize; (2 * max + 1) as usize];
let mut trace: Vec<Vec<isize>> = Vec::new();
'search: for d in 0..=max {
trace.push(v.clone());
let mut k = -d;
while k <= d {
let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { v[idx(k + 1)] } else { v[idx(k - 1)] + 1 };
let mut y = x - k;
while x < n && y < m && a[x as usize] == b[y as usize] {
x += 1;
y += 1;
}
v[idx(k)] = x;
if x >= n && y >= m {
break 'search;
}
k += 2;
}
}
let mut edits = Vec::new();
let (mut x, mut y) = (n, m);
for (d, v) in trace.iter().enumerate().rev() {
let d = d as isize;
let k = x - y;
let prev_k = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { k + 1 } else { k - 1 };
let prev_x = v[idx(prev_k)];
let prev_y = prev_x - prev_k;
while x > prev_x && y > prev_y {
edits.push(Edit::Keep);
x -= 1;
y -= 1;
}
if d > 0 {
edits.push(if x == prev_x { Edit::Add } else { Edit::Del });
}
x = prev_x;
y = prev_y;
}
edits.reverse();
edits
}
struct Rec {
line: Line,
old_before: u32,
new_before: u32,
changed: bool,
}
fn records(a: &[&str], b: &[&str], edits: &[Edit]) -> Vec<Rec> {
let (mut ai, mut bi) = (0usize, 0usize);
let (mut old_no, mut new_no) = (1u32, 1u32);
let mut recs = Vec::with_capacity(edits.len());
for e in edits {
match e {
Edit::Keep => {
recs.push(Rec {
line: Line::Ctx { old: old_no, new: new_no, text: a[ai].to_string() },
old_before: old_no,
new_before: new_no,
changed: false,
});
ai += 1;
bi += 1;
old_no += 1;
new_no += 1;
}
Edit::Del => {
recs.push(Rec {
line: Line::Del { old: old_no, text: a[ai].to_string() },
old_before: old_no,
new_before: new_no,
changed: true,
});
ai += 1;
old_no += 1;
}
Edit::Add => {
recs.push(Rec {
line: Line::Add { new: new_no, text: b[bi].to_string() },
old_before: old_no,
new_before: new_no,
changed: true,
});
bi += 1;
new_no += 1;
}
}
}
recs
}
fn hunks_from(recs: &[Rec], context: usize) -> Vec<Hunk> {
let mut include = vec![false; recs.len()];
for (i, r) in recs.iter().enumerate() {
if r.changed {
let lo = i.saturating_sub(context);
let hi = (i + context).min(recs.len() - 1);
for flag in &mut include[lo..=hi] {
*flag = true;
}
}
}
let mut hunks = Vec::new();
let mut i = 0;
while i < recs.len() {
if !include[i] {
i += 1;
continue;
}
let start = i;
while i < recs.len() && include[i] {
i += 1;
}
let slice = &recs[start..i];
let old_count = slice.iter().filter(|r| matches!(r.line, Line::Del { .. } | Line::Ctx { .. })).count();
let new_count = slice.iter().filter(|r| matches!(r.line, Line::Add { .. } | Line::Ctx { .. })).count();
let old_start = if old_count > 0 { slice[0].old_before } else { slice[0].old_before.saturating_sub(1) };
let new_start = if new_count > 0 { slice[0].new_before } else { slice[0].new_before.saturating_sub(1) };
hunks.push(Hunk {
header: format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@"),
lines: slice.iter().map(|r| r.line.clone()).collect(),
});
}
hunks
}
#[cfg(test)]
mod tests {
use super::*;
fn snap() -> RangeSnapshots {
let blob = |s: &str| Some(s.to_string());
RangeSnapshots {
blobs: BTreeMap::from([
("b-one".into(), blob("alpha\nbeta\ngamma\n")),
("b-two".into(), blob("alpha\nBETA\ngamma\n")),
("b-new".into(), blob("fresh\n")),
("b-bin".into(), None),
]),
boundaries: vec![
Boundary {
sha: "s0".into(),
files: BTreeMap::from([("keep.txt".into(), "b-one".into()), ("gone.txt".into(), "b-one".into())]),
},
Boundary {
sha: "s1".into(),
files: BTreeMap::from([("keep.txt".into(), "b-two".into()), ("gone.txt".into(), "b-one".into())]),
},
Boundary {
sha: "s2".into(),
files: BTreeMap::from([
("keep.txt".into(), "b-one".into()),
("new.txt".into(), "b-new".into()),
("blob.bin".into(), "b-bin".into()),
]),
},
],
}
}
#[test]
fn single_commit_diff() {
let files = range_diff(&snap(), 0, 1, 3).unwrap();
assert_eq!(files.len(), 1);
let f = &files[0];
assert_eq!(f.new_path.as_deref(), Some("keep.txt"));
assert_eq!(f.status, FileStatus::Modified);
assert_eq!((f.additions, f.deletions), (1, 1));
assert_eq!(f.hunks[0].header, "@@ -1,3 +1,3 @@");
assert_eq!(f.hunks[0].lines[1], Line::Del { old: 2, text: "beta".into() });
assert_eq!(f.hunks[0].lines[2], Line::Add { new: 2, text: "BETA".into() });
}
#[test]
fn full_range_hides_a_change_that_was_reverted() {
let files = range_diff(&snap(), 0, 2, 3).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.anchor_path()).collect();
assert_eq!(paths, vec!["blob.bin", "gone.txt", "new.txt"]);
let files = range_diff(&snap(), 1, 2, 3).unwrap();
assert!(files.iter().any(|f| f.anchor_path() == "keep.txt"));
}
#[test]
fn added_deleted_and_binary_statuses() {
let files = range_diff(&snap(), 0, 2, 3).unwrap();
let by_path = |p: &str| files.iter().find(|f| f.anchor_path() == p).unwrap();
let added = by_path("new.txt");
assert_eq!(added.status, FileStatus::Added);
assert_eq!(added.old_path, None);
assert_eq!(added.hunks[0].header, "@@ -0,0 +1,1 @@");
let deleted = by_path("gone.txt");
assert_eq!(deleted.status, FileStatus::Deleted);
assert_eq!(deleted.new_path, None);
assert_eq!(deleted.deletions, 3);
let binary = by_path("blob.bin");
assert!(binary.binary);
assert!(binary.hunks.is_empty());
}
#[test]
fn invalid_ranges_and_missing_blobs_are_loud() {
assert!(matches!(range_diff(&snap(), 1, 1, 3), Err(ModelError::InvalidRange(_))));
assert!(matches!(range_diff(&snap(), 2, 1, 3), Err(ModelError::InvalidRange(_))));
assert!(matches!(range_diff(&snap(), 0, 9, 3), Err(ModelError::InvalidRange(_))));
let mut broken = snap();
broken.blobs.remove("b-two");
assert!(matches!(range_diff(&broken, 0, 1, 3), Err(ModelError::InvalidRange(_))));
}
#[test]
fn diff_lines_context_and_merging() {
let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
let new = "a\nB\nc\nd\ne\nf\ng\nh\nI\nj\n";
let hunks = diff_lines(old, new, 1);
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].header, "@@ -1,3 +1,3 @@");
assert_eq!(hunks[1].header, "@@ -8,3 +8,3 @@");
let hunks = diff_lines(old, new, 3);
assert_eq!(hunks.len(), 1, "windows overlap and merge");
assert_eq!(hunks[0].header, "@@ -1,10 +1,10 @@");
}
#[test]
fn diff_lines_edge_cases() {
assert!(diff_lines("", "", 3).is_empty());
assert!(diff_lines("same\n", "same\n", 3).is_empty());
let from_empty = diff_lines("", "one\ntwo\n", 3);
assert_eq!(from_empty[0].header, "@@ -0,0 +1,2 @@");
let to_empty = diff_lines("one\ntwo\n", "", 3);
assert_eq!(to_empty[0].header, "@@ -1,2 +0,0 @@");
}
#[test]
fn myers_produces_a_minimal_script() {
let a: Vec<&str> = "A B C A B B A".split(' ').collect();
let b: Vec<&str> = "C B A B A C".split(' ').collect();
let edits = myers_edits(&a, &b);
let changes = edits.iter().filter(|e| **e != Edit::Keep).count();
assert_eq!(changes, 5);
let keeps = edits.iter().filter(|e| **e == Edit::Keep).count();
assert_eq!(keeps, 4);
let (mut ai, mut bi) = (0, 0);
let mut out: Vec<&str> = Vec::new();
for e in &edits {
match e {
Edit::Keep => {
out.push(a[ai]);
ai += 1;
bi += 1;
}
Edit::Del => ai += 1,
Edit::Add => {
out.push(b[bi]);
bi += 1;
}
}
}
assert_eq!(out, b);
assert_eq!((ai, bi), (a.len(), b.len()));
}
#[test]
fn snapshots_roundtrip_and_reject_unknown_fields() {
let s = snap();
let json = serde_json::to_string(&s).unwrap();
let back: RangeSnapshots = serde_json::from_str(&json).unwrap();
assert_eq!(back.boundaries.len(), 3);
let sneaky = json.replacen("{\"blobs\"", "{\"sneaky\":true,\"blobs\"", 1);
assert!(serde_json::from_str::<RangeSnapshots>(&sneaky).is_err(), "unknown fields are strict-rejected");
}
}