1use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::diff::{FileDiff, FileStatus, Hunk, Line};
14use crate::ModelError;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct RangeSnapshots {
22 pub blobs: BTreeMap<String, Option<String>>,
26 pub boundaries: Vec<Boundary>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Boundary {
35 pub sha: String,
37 pub files: BTreeMap<String, String>,
40}
41
42pub fn range_diff(snap: &RangeSnapshots, from: usize, to: usize, context: usize) -> Result<Vec<FileDiff>, ModelError> {
46 if from >= to || to >= snap.boundaries.len() {
47 return Err(ModelError::InvalidRange(format!(
48 "from {from} / to {to} against {} boundaries",
49 snap.boundaries.len()
50 )));
51 }
52 let lo = &snap.boundaries[from];
53 let hi = &snap.boundaries[to];
54 let mut paths: BTreeSet<&String> = lo.files.keys().collect();
55 paths.extend(hi.files.keys());
56
57 let content = |id: &String| -> Result<&Option<String>, ModelError> {
58 snap.blobs.get(id).ok_or_else(|| ModelError::InvalidRange(format!("blob {id} missing from the snapshot store")))
59 };
60
61 let mut files = Vec::new();
62 for path in paths {
63 let old_id = lo.files.get(path);
64 let new_id = hi.files.get(path);
65 if old_id == new_id {
66 continue; }
68 let status = match (old_id, new_id) {
69 (None, Some(_)) => FileStatus::Added,
70 (Some(_), None) => FileStatus::Deleted,
71 _ => FileStatus::Modified,
72 };
73 let old_blob = old_id.map(content).transpose()?;
74 let new_blob = new_id.map(content).transpose()?;
75 let old_path = old_id.map(|_| path.clone());
76 let new_path = new_id.map(|_| path.clone());
77 if matches!(old_blob, Some(None)) || matches!(new_blob, Some(None)) {
79 files.push(FileDiff {
80 old_path,
81 new_path,
82 status,
83 binary: true,
84 hunks: Vec::new(),
85 additions: 0,
86 deletions: 0,
87 notes: Vec::new(),
88 });
89 continue;
90 }
91 let old_text = old_blob.and_then(|b| b.as_deref()).unwrap_or("");
92 let new_text = new_blob.and_then(|b| b.as_deref()).unwrap_or("");
93 let hunks = diff_lines(old_text, new_text, context);
94 let additions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Add { .. })).count() as u32;
95 let deletions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Del { .. })).count() as u32;
96 if hunks.is_empty() {
97 continue; }
99 files.push(FileDiff { old_path, new_path, status, binary: false, hunks, additions, deletions, notes: Vec::new() });
100 }
101 Ok(files)
102}
103
104pub fn context_slice(
112 snap: &RangeSnapshots, old_path: &str, new_path: &str, old_start: u32, new_start: u32, count: u32,
113) -> Result<Vec<Line>, ModelError> {
114 if old_start == 0 || new_start == 0 {
115 return Err(ModelError::InvalidRange("line numbers are 1-based".to_string()));
116 }
117 let (Some(base), Some(head)) = (snap.boundaries.first(), snap.boundaries.last()) else {
118 return Err(ModelError::InvalidRange("snapshot store has no boundaries".to_string()));
119 };
120 let text_at = |boundary: &Boundary, path: &str, side: &str| -> Result<String, ModelError> {
121 let id = boundary
122 .files
123 .get(path)
124 .ok_or_else(|| ModelError::InvalidRange(format!("{path} does not exist at the {side} boundary")))?;
125 let blob = snap
126 .blobs
127 .get(id)
128 .ok_or_else(|| ModelError::InvalidRange(format!("blob {id} missing from the snapshot store")))?;
129 blob.clone().ok_or_else(|| ModelError::InvalidRange(format!("{path} was not snapshotted (binary or oversized)")))
130 };
131 let old_text = text_at(base, old_path, "base")?;
132 let new_text = text_at(head, new_path, "head")?;
133 let old_lines: Vec<&str> = old_text.lines().collect();
134 let new_lines: Vec<&str> = new_text.lines().collect();
135 let mut out = Vec::new();
136 for i in 0..count {
137 let (Some(old_no), Some(new_no)) = (old_start.checked_add(i), new_start.checked_add(i)) else { break };
138 let (Some(old), Some(new)) = (old_lines.get(old_no as usize - 1), new_lines.get(new_no as usize - 1)) else {
139 break; };
141 if old != new {
142 return Err(ModelError::InvalidRange(format!(
143 "old line {old_no} and new line {new_no} differ between the endpoints — not an unchanged region"
144 )));
145 }
146 out.push(Line::Ctx { old: old_no, new: new_no, text: (*old).to_string() });
147 }
148 Ok(out)
149}
150
151pub fn diff_lines(old: &str, new: &str, context: usize) -> Vec<Hunk> {
155 let a: Vec<&str> = old.lines().collect();
156 let b: Vec<&str> = new.lines().collect();
157 let edits = myers_edits(&a, &b);
158 hunks_from(&records(&a, &b, &edits), context)
159}
160
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
164enum Edit {
165 Keep,
166 Del,
167 Add,
168}
169
170fn myers_edits(a: &[&str], b: &[&str]) -> Vec<Edit> {
173 let n = a.len() as isize;
174 let m = b.len() as isize;
175 let max = n + m;
176 if max == 0 {
177 return Vec::new();
178 }
179 let offset = max;
180 let idx = |k: isize| (k + offset) as usize;
181 let mut v = vec![0isize; (2 * max + 1) as usize];
182 let mut trace: Vec<Vec<isize>> = Vec::new();
183 'search: for d in 0..=max {
184 trace.push(v.clone());
185 let mut k = -d;
186 while k <= d {
187 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 };
188 let mut y = x - k;
189 while x < n && y < m && a[x as usize] == b[y as usize] {
190 x += 1;
191 y += 1;
192 }
193 v[idx(k)] = x;
194 if x >= n && y >= m {
195 break 'search;
196 }
197 k += 2;
198 }
199 }
200
201 let mut edits = Vec::new();
203 let (mut x, mut y) = (n, m);
204 for (d, v) in trace.iter().enumerate().rev() {
205 let d = d as isize;
206 let k = x - y;
207 let prev_k = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { k + 1 } else { k - 1 };
208 let prev_x = v[idx(prev_k)];
209 let prev_y = prev_x - prev_k;
210 while x > prev_x && y > prev_y {
211 edits.push(Edit::Keep);
212 x -= 1;
213 y -= 1;
214 }
215 if d > 0 {
216 edits.push(if x == prev_x { Edit::Add } else { Edit::Del });
217 }
218 x = prev_x;
219 y = prev_y;
220 }
221 edits.reverse();
222 edits
223}
224
225struct Rec {
228 line: Line,
229 old_before: u32,
230 new_before: u32,
231 changed: bool,
232}
233
234fn records(a: &[&str], b: &[&str], edits: &[Edit]) -> Vec<Rec> {
235 let (mut ai, mut bi) = (0usize, 0usize);
236 let (mut old_no, mut new_no) = (1u32, 1u32);
237 let mut recs = Vec::with_capacity(edits.len());
238 for e in edits {
239 match e {
240 Edit::Keep => {
241 recs.push(Rec {
242 line: Line::Ctx { old: old_no, new: new_no, text: a[ai].to_string() },
243 old_before: old_no,
244 new_before: new_no,
245 changed: false,
246 });
247 ai += 1;
248 bi += 1;
249 old_no += 1;
250 new_no += 1;
251 }
252 Edit::Del => {
253 recs.push(Rec {
254 line: Line::Del { old: old_no, text: a[ai].to_string() },
255 old_before: old_no,
256 new_before: new_no,
257 changed: true,
258 });
259 ai += 1;
260 old_no += 1;
261 }
262 Edit::Add => {
263 recs.push(Rec {
264 line: Line::Add { new: new_no, text: b[bi].to_string() },
265 old_before: old_no,
266 new_before: new_no,
267 changed: true,
268 });
269 bi += 1;
270 new_no += 1;
271 }
272 }
273 }
274 recs
275}
276
277fn hunks_from(recs: &[Rec], context: usize) -> Vec<Hunk> {
282 let mut include = vec![false; recs.len()];
283 for (i, r) in recs.iter().enumerate() {
284 if r.changed {
285 let lo = i.saturating_sub(context);
286 let hi = (i + context).min(recs.len() - 1);
287 for flag in &mut include[lo..=hi] {
288 *flag = true;
289 }
290 }
291 }
292 let mut hunks = Vec::new();
293 let mut i = 0;
294 while i < recs.len() {
295 if !include[i] {
296 i += 1;
297 continue;
298 }
299 let start = i;
300 while i < recs.len() && include[i] {
301 i += 1;
302 }
303 let slice = &recs[start..i];
304 let old_count = slice.iter().filter(|r| matches!(r.line, Line::Del { .. } | Line::Ctx { .. })).count();
305 let new_count = slice.iter().filter(|r| matches!(r.line, Line::Add { .. } | Line::Ctx { .. })).count();
306 let old_start = if old_count > 0 { slice[0].old_before } else { slice[0].old_before.saturating_sub(1) };
307 let new_start = if new_count > 0 { slice[0].new_before } else { slice[0].new_before.saturating_sub(1) };
308 hunks.push(Hunk {
309 header: format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@"),
310 lines: slice.iter().map(|r| r.line.clone()).collect(),
311 });
312 }
313 hunks
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn snap() -> RangeSnapshots {
321 let blob = |s: &str| Some(s.to_string());
322 RangeSnapshots {
323 blobs: BTreeMap::from([
324 ("b-one".into(), blob("alpha\nbeta\ngamma\n")),
325 ("b-two".into(), blob("alpha\nBETA\ngamma\n")),
326 ("b-new".into(), blob("fresh\n")),
327 ("b-bin".into(), None),
328 ]),
329 boundaries: vec![
330 Boundary {
331 sha: "s0".into(),
332 files: BTreeMap::from([("keep.txt".into(), "b-one".into()), ("gone.txt".into(), "b-one".into())]),
333 },
334 Boundary {
335 sha: "s1".into(),
336 files: BTreeMap::from([("keep.txt".into(), "b-two".into()), ("gone.txt".into(), "b-one".into())]),
337 },
338 Boundary {
339 sha: "s2".into(),
340 files: BTreeMap::from([
341 ("keep.txt".into(), "b-one".into()),
342 ("new.txt".into(), "b-new".into()),
343 ("blob.bin".into(), "b-bin".into()),
344 ]),
345 },
346 ],
347 }
348 }
349
350 #[test]
351 fn single_commit_diff() {
352 let files = range_diff(&snap(), 0, 1, 3).unwrap();
353 assert_eq!(files.len(), 1);
354 let f = &files[0];
355 assert_eq!(f.new_path.as_deref(), Some("keep.txt"));
356 assert_eq!(f.status, FileStatus::Modified);
357 assert_eq!((f.additions, f.deletions), (1, 1));
358 assert_eq!(f.hunks[0].header, "@@ -1,3 +1,3 @@");
359 assert_eq!(f.hunks[0].lines[1], Line::Del { old: 2, text: "beta".into() });
360 assert_eq!(f.hunks[0].lines[2], Line::Add { new: 2, text: "BETA".into() });
361 }
362
363 #[test]
364 fn full_range_hides_a_change_that_was_reverted() {
365 let files = range_diff(&snap(), 0, 2, 3).unwrap();
367 let paths: Vec<&str> = files.iter().map(|f| f.anchor_path()).collect();
368 assert_eq!(paths, vec!["blob.bin", "gone.txt", "new.txt"]);
369 let files = range_diff(&snap(), 1, 2, 3).unwrap();
371 assert!(files.iter().any(|f| f.anchor_path() == "keep.txt"));
372 }
373
374 #[test]
375 fn added_deleted_and_binary_statuses() {
376 let files = range_diff(&snap(), 0, 2, 3).unwrap();
377 let by_path = |p: &str| files.iter().find(|f| f.anchor_path() == p).unwrap();
378 let added = by_path("new.txt");
379 assert_eq!(added.status, FileStatus::Added);
380 assert_eq!(added.old_path, None);
381 assert_eq!(added.hunks[0].header, "@@ -0,0 +1,1 @@");
382 let deleted = by_path("gone.txt");
383 assert_eq!(deleted.status, FileStatus::Deleted);
384 assert_eq!(deleted.new_path, None);
385 assert_eq!(deleted.deletions, 3);
386 let binary = by_path("blob.bin");
387 assert!(binary.binary);
388 assert!(binary.hunks.is_empty());
389 }
390
391 #[test]
392 fn invalid_ranges_and_missing_blobs_are_loud() {
393 assert!(matches!(range_diff(&snap(), 1, 1, 3), Err(ModelError::InvalidRange(_))));
394 assert!(matches!(range_diff(&snap(), 2, 1, 3), Err(ModelError::InvalidRange(_))));
395 assert!(matches!(range_diff(&snap(), 0, 9, 3), Err(ModelError::InvalidRange(_))));
396 let mut broken = snap();
397 broken.blobs.remove("b-two");
398 assert!(matches!(range_diff(&broken, 0, 1, 3), Err(ModelError::InvalidRange(_))));
399 }
400
401 #[test]
402 fn context_slice_returns_clamped_ctx_lines() {
403 let lines = context_slice(&snap(), "keep.txt", "keep.txt", 1, 1, 10).unwrap();
405 assert_eq!(lines.len(), 3, "clamped at end of file");
406 assert_eq!(lines[0], Line::Ctx { old: 1, new: 1, text: "alpha".into() });
407 assert_eq!(lines[2], Line::Ctx { old: 3, new: 3, text: "gamma".into() });
408 let one = context_slice(&snap(), "keep.txt", "keep.txt", 2, 2, 1).unwrap();
409 assert_eq!(one, vec![Line::Ctx { old: 2, new: 2, text: "beta".into() }]);
410 assert!(context_slice(&snap(), "keep.txt", "keep.txt", 9, 9, 5).unwrap().is_empty(), "past EOF: empty, not error");
411 }
412
413 #[test]
414 fn context_slice_follows_renames_and_offset_numbering() {
415 let s = RangeSnapshots {
416 blobs: BTreeMap::from([("b".into(), Some("one\ntwo\nthree\nfour\n".to_string()))]),
417 boundaries: vec![
418 Boundary { sha: "s0".into(), files: BTreeMap::from([("old name.txt".into(), "b".into())]) },
419 Boundary { sha: "s1".into(), files: BTreeMap::from([("new name.txt".into(), "b".into())]) },
420 ],
421 };
422 let lines = context_slice(&s, "old name.txt", "new name.txt", 3, 3, 2).unwrap();
425 assert_eq!(lines[0], Line::Ctx { old: 3, new: 3, text: "three".into() });
426 }
427
428 #[test]
429 fn context_slice_rejects_changed_regions_and_bad_input() {
430 let differing = RangeSnapshots {
431 blobs: BTreeMap::from([
432 ("b-one".into(), Some("alpha\nbeta\n".to_string())),
433 ("b-two".into(), Some("alpha\nBETA\n".to_string())),
434 ("b-bin".into(), None),
435 ]),
436 boundaries: vec![
437 Boundary {
438 sha: "s0".into(),
439 files: BTreeMap::from([("f.txt".into(), "b-one".into()), ("blob.bin".into(), "b-bin".into())]),
440 },
441 Boundary {
442 sha: "s1".into(),
443 files: BTreeMap::from([("f.txt".into(), "b-two".into()), ("blob.bin".into(), "b-bin".into())]),
444 },
445 ],
446 };
447 assert!(context_slice(&differing, "f.txt", "f.txt", 1, 1, 1).is_ok());
450 assert!(matches!(context_slice(&differing, "f.txt", "f.txt", 1, 1, 2), Err(ModelError::InvalidRange(_))));
451 assert!(matches!(context_slice(&differing, "blob.bin", "blob.bin", 1, 1, 1), Err(ModelError::InvalidRange(_))));
453 assert!(matches!(context_slice(&differing, "nope", "nope", 1, 1, 1), Err(ModelError::InvalidRange(_))));
454 assert!(matches!(context_slice(&differing, "f.txt", "f.txt", 0, 1, 1), Err(ModelError::InvalidRange(_))));
455 }
456
457 #[test]
458 fn diff_lines_context_and_merging() {
459 let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
460 let new = "a\nB\nc\nd\ne\nf\ng\nh\nI\nj\n";
462 let hunks = diff_lines(old, new, 1);
463 assert_eq!(hunks.len(), 2);
464 assert_eq!(hunks[0].header, "@@ -1,3 +1,3 @@");
465 assert_eq!(hunks[1].header, "@@ -8,3 +8,3 @@");
466 let hunks = diff_lines(old, new, 3);
467 assert_eq!(hunks.len(), 1, "windows overlap and merge");
468 assert_eq!(hunks[0].header, "@@ -1,10 +1,10 @@");
469 }
470
471 #[test]
472 fn diff_lines_edge_cases() {
473 assert!(diff_lines("", "", 3).is_empty());
474 assert!(diff_lines("same\n", "same\n", 3).is_empty());
475 let from_empty = diff_lines("", "one\ntwo\n", 3);
476 assert_eq!(from_empty[0].header, "@@ -0,0 +1,2 @@");
477 let to_empty = diff_lines("one\ntwo\n", "", 3);
478 assert_eq!(to_empty[0].header, "@@ -1,2 +0,0 @@");
479 }
480
481 #[test]
482 fn myers_produces_a_minimal_script() {
483 let a: Vec<&str> = "A B C A B B A".split(' ').collect();
485 let b: Vec<&str> = "C B A B A C".split(' ').collect();
486 let edits = myers_edits(&a, &b);
487 let changes = edits.iter().filter(|e| **e != Edit::Keep).count();
488 assert_eq!(changes, 5);
489 let keeps = edits.iter().filter(|e| **e == Edit::Keep).count();
490 assert_eq!(keeps, 4);
491 let (mut ai, mut bi) = (0, 0);
493 let mut out: Vec<&str> = Vec::new();
494 for e in &edits {
495 match e {
496 Edit::Keep => {
497 out.push(a[ai]);
498 ai += 1;
499 bi += 1;
500 }
501 Edit::Del => ai += 1,
502 Edit::Add => {
503 out.push(b[bi]);
504 bi += 1;
505 }
506 }
507 }
508 assert_eq!(out, b);
509 assert_eq!((ai, bi), (a.len(), b.len()));
510 }
511
512 #[test]
513 fn snapshots_roundtrip_and_reject_unknown_fields() {
514 let s = snap();
515 let json = serde_json::to_string(&s).unwrap();
516 let back: RangeSnapshots = serde_json::from_str(&json).unwrap();
517 assert_eq!(back.boundaries.len(), 3);
518 let sneaky = json.replacen("{\"blobs\"", "{\"sneaky\":true,\"blobs\"", 1);
519 assert!(serde_json::from_str::<RangeSnapshots>(&sneaky).is_err(), "unknown fields are strict-rejected");
520 }
521}