1use serde::{Deserialize, Serialize};
9
10use crate::{RefInfo, SCHEMA_VERSION, TOOL};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct DiffDocument {
16 pub schema_version: u32,
19 pub tool: String,
22 pub repo: String,
25 pub base: RefInfo,
27 pub head: RefInfo,
29 pub merge_base: String,
32 pub generated_at: String,
35 pub commits: Vec<Commit>,
37 pub files: Vec<FileDiff>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub snapshots: Option<crate::snapshot::RangeSnapshots>,
45}
46
47impl DiffDocument {
48 #[allow(clippy::too_many_arguments)]
49 pub fn new(
50 repo: String, base: RefInfo, head: RefInfo, merge_base: String, generated_at: String, commits: Vec<Commit>,
51 files: Vec<FileDiff>, snapshots: Option<crate::snapshot::RangeSnapshots>,
52 ) -> Self {
53 Self {
54 schema_version: SCHEMA_VERSION,
55 tool: TOOL.to_string(),
56 repo,
57 base,
58 head,
59 merge_base,
60 generated_at,
61 commits,
62 files,
63 snapshots,
64 }
65 }
66
67 pub fn additions(&self) -> u32 {
68 self.files.iter().map(|f| f.additions).sum()
69 }
70
71 pub fn deletions(&self) -> u32 {
72 self.files.iter().map(|f| f.deletions).sum()
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Commit {
80 pub sha: String,
82 pub short: String,
84 pub author: String,
86 pub email: String,
89 pub date: String,
91 pub subject: String,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98pub enum FileStatus {
99 Added,
101 Deleted,
103 Modified,
105 Renamed,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct FileDiff {
114 pub old_path: Option<String>,
116 pub new_path: Option<String>,
118 pub status: FileStatus,
120 pub binary: bool,
122 pub hunks: Vec<Hunk>,
125 pub additions: u32,
128 pub deletions: u32,
130 pub notes: Vec<String>,
132}
133
134impl FileDiff {
135 pub fn display_path(&self) -> String {
137 match (self.status, &self.old_path, &self.new_path) {
138 (FileStatus::Renamed, Some(old), Some(new)) if old != new => format!("{old} → {new}"),
139 _ => self.anchor_path().to_string(),
140 }
141 }
142
143 pub fn anchor_path(&self) -> &str {
145 self.new_path.as_deref().or(self.old_path.as_deref()).unwrap_or("")
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct Hunk {
153 pub header: String,
155 pub lines: Vec<Line>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub enum Line {
167 Add {
169 new: u32,
171 text: String,
173 },
174 Del {
176 old: u32,
178 text: String,
180 },
181 Ctx {
183 old: u32,
185 new: u32,
187 text: String,
189 },
190 Meta {
193 text: String,
195 },
196}
197
198pub fn parse_unified_diff(text: &str) -> Vec<FileDiff> {
200 let mut files: Vec<FileDiff> = Vec::new();
201 let mut cur: Option<FileDiff> = None;
202 let mut in_hunk = false;
203 let mut old_no: u32 = 0;
204 let mut new_no: u32 = 0;
205
206 for line in text.lines() {
207 if let Some(rest) = line.strip_prefix("diff --git ") {
208 if let Some(done) = cur.take() {
209 files.push(done);
210 }
211 let (old, new) = split_ab(rest);
212 cur = Some(FileDiff {
213 old_path: Some(old),
214 new_path: Some(new),
215 status: FileStatus::Modified,
216 binary: false,
217 hunks: Vec::new(),
218 additions: 0,
219 deletions: 0,
220 notes: Vec::new(),
221 });
222 in_hunk = false;
223 continue;
224 }
225 let Some(f) = cur.as_mut() else { continue };
226
227 if in_hunk {
228 if let Some(body) = line.strip_prefix('+') {
231 f.hunks
232 .last_mut()
233 .expect("in_hunk implies a current hunk")
234 .lines
235 .push(Line::Add { new: new_no, text: body.to_string() });
236 new_no += 1;
237 f.additions += 1;
238 continue;
239 }
240 if let Some(body) = line.strip_prefix('-') {
241 f.hunks
242 .last_mut()
243 .expect("in_hunk implies a current hunk")
244 .lines
245 .push(Line::Del { old: old_no, text: body.to_string() });
246 old_no += 1;
247 f.deletions += 1;
248 continue;
249 }
250 if let Some(body) = line.strip_prefix(' ') {
251 f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Ctx {
252 old: old_no,
253 new: new_no,
254 text: body.to_string(),
255 });
256 old_no += 1;
257 new_no += 1;
258 continue;
259 }
260 if line.starts_with('\\') {
261 f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Meta { text: line.to_string() });
262 continue;
263 }
264 in_hunk = false; }
266
267 if let Some((o, n)) = parse_hunk_header(line) {
268 old_no = o;
269 new_no = n;
270 f.hunks.push(Hunk { header: line.to_string(), lines: Vec::new() });
271 in_hunk = true;
272 } else if line.starts_with("new file mode") {
273 f.status = FileStatus::Added;
274 f.old_path = None;
275 } else if line.starts_with("deleted file mode") {
276 f.status = FileStatus::Deleted;
277 f.new_path = None;
278 } else if let Some(p) = line.strip_prefix("rename from ") {
279 f.status = FileStatus::Renamed;
280 f.old_path = Some(p.to_string());
281 } else if let Some(p) = line.strip_prefix("rename to ") {
282 f.status = FileStatus::Renamed;
283 f.new_path = Some(p.to_string());
284 } else if line.starts_with("old mode ") || line.starts_with("new mode ") {
285 f.notes.push(line.to_string());
286 } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
287 f.binary = true;
288 } else if let Some(p) = line.strip_prefix("--- ") {
289 if let Some(stripped) = strip_prefix_path(p) {
290 f.old_path = Some(stripped);
291 } else if f.status != FileStatus::Added {
292 f.status = FileStatus::Added;
294 f.old_path = None;
295 }
296 } else if let Some(p) = line.strip_prefix("+++ ") {
297 if let Some(stripped) = strip_prefix_path(p) {
298 f.new_path = Some(stripped);
299 } else if f.status != FileStatus::Deleted {
300 f.status = FileStatus::Deleted;
301 f.new_path = None;
302 }
303 }
304 }
306 if let Some(done) = cur.take() {
307 files.push(done);
308 }
309 files
310}
311
312fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
314 let rest = line.strip_prefix("@@ -")?;
315 let (old_part, rest) = rest.split_once(" +")?;
316 let (new_part, _) = rest.split_once(" @@")?;
317 let old = old_part.split(',').next()?.parse().ok()?;
318 let new = new_part.split(',').next()?.parse().ok()?;
319 Some((old, new))
320}
321
322fn strip_prefix_path(path: &str) -> Option<String> {
324 if path == "/dev/null" {
325 return None;
326 }
327 let stripped = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
328 Some(stripped.to_string())
329}
330
331fn split_ab(rest: &str) -> (String, String) {
335 if let Some(stripped) = rest.strip_prefix('"') {
336 if let Some((old, new)) = stripped.split_once("\" \"") {
338 let old = old.strip_prefix("a/").unwrap_or(old);
339 let new = new.strip_prefix("b/").unwrap_or(new).trim_end_matches('"');
340 return (old.to_string(), new.to_string());
341 }
342 }
343 if let Some(idx) = rest.rfind(" b/") {
344 let old = strip_prefix_path(&rest[..idx]).unwrap_or_default();
345 let new = rest[idx + 3..].to_string();
346 return (old, new);
347 }
348 (rest.to_string(), rest.to_string())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 const SAMPLE: &str = "\
356diff --git a/hello.py b/hello.py
357index 1111111..2222222 100644
358--- a/hello.py
359+++ b/hello.py
360@@ -1,2 +1,4 @@
361 def hello():
362- return 'hi'
363+ return 'hello'
364+
365+# trailing
366diff --git a/gone.txt b/gone.txt
367deleted file mode 100644
368index 3333333..0000000
369--- a/gone.txt
370+++ /dev/null
371@@ -1 +0,0 @@
372-obsolete
373\\ No newline at end of file
374diff --git a/old name.txt b/new name.txt
375similarity index 90%
376rename from old name.txt
377rename to new name.txt
378diff --git a/fresh.md b/fresh.md
379new file mode 100644
380index 0000000..4444444
381--- /dev/null
382+++ b/fresh.md
383@@ -0,0 +1,2 @@
384+# Fresh
385+body
386diff --git a/blob.bin b/blob.bin
387index 5555555..6666666 100644
388Binary files a/blob.bin and b/blob.bin differ
389";
390
391 fn parsed() -> Vec<FileDiff> {
392 parse_unified_diff(SAMPLE)
393 }
394
395 #[test]
396 fn parses_all_files() {
397 assert_eq!(parsed().len(), 5);
398 }
399
400 #[test]
401 fn modified_file_lines_and_numbers() {
402 let files = parsed();
403 let f = &files[0];
404 assert_eq!(f.status, FileStatus::Modified);
405 assert_eq!((f.additions, f.deletions), (3, 1));
406 let lines = &f.hunks[0].lines;
407 assert_eq!(lines[0], Line::Ctx { old: 1, new: 1, text: "def hello():".into() });
408 assert_eq!(lines[1], Line::Del { old: 2, text: " return 'hi'".into() });
409 assert_eq!(lines[2], Line::Add { new: 2, text: " return 'hello'".into() });
410 assert_eq!(lines[4], Line::Add { new: 4, text: "# trailing".into() });
411 }
412
413 #[test]
414 fn deleted_file() {
415 let files = parsed();
416 let f = &files[1];
417 assert_eq!(f.status, FileStatus::Deleted);
418 assert_eq!(f.new_path, None);
419 assert_eq!(f.anchor_path(), "gone.txt");
420 assert!(matches!(f.hunks[0].lines.last(), Some(Line::Meta { .. })));
421 }
422
423 #[test]
424 fn rename_with_spaces_in_paths() {
425 let files = parsed();
426 let f = &files[2];
427 assert_eq!(f.status, FileStatus::Renamed);
428 assert_eq!(f.old_path.as_deref(), Some("old name.txt"));
429 assert_eq!(f.new_path.as_deref(), Some("new name.txt"));
430 assert_eq!(f.display_path(), "old name.txt → new name.txt");
431 assert!(f.hunks.is_empty());
432 }
433
434 #[test]
435 fn added_file() {
436 let files = parsed();
437 let f = &files[3];
438 assert_eq!(f.status, FileStatus::Added);
439 assert_eq!(f.old_path, None);
440 assert_eq!(f.additions, 2);
441 }
442
443 #[test]
444 fn binary_file() {
445 let files = parsed();
446 assert!(files[4].binary);
447 assert!(files[4].hunks.is_empty());
448 }
449
450 #[test]
451 fn hunk_header_forms() {
452 assert_eq!(parse_hunk_header("@@ -1,2 +3,4 @@"), Some((1, 3)));
453 assert_eq!(parse_hunk_header("@@ -1 +0,0 @@"), Some((1, 0)));
454 assert_eq!(parse_hunk_header("@@ -10,5 +12,7 @@ fn ctx()"), Some((10, 12)));
455 assert_eq!(parse_hunk_header("not a hunk"), None);
456 }
457
458 #[test]
459 fn lines_encode_as_single_key_unions() {
460 let add = Line::Add { new: 2, text: "x".into() };
461 assert_eq!(serde_json::to_value(&add).unwrap(), serde_json::json!({ "Add": { "new": 2, "text": "x" } }));
462 assert_eq!(serde_json::to_value(FileStatus::Renamed).unwrap(), serde_json::json!("Renamed"));
463 }
464
465 #[test]
466 fn unknown_fields_are_rejected() {
467 let bad = r#"{ "Add": { "new": 2, "text": "x", "sneaky": true } }"#;
468 assert!(serde_json::from_str::<Line>(bad).is_err());
469 }
470
471 #[test]
472 fn document_roundtrips_through_json() {
473 let doc = DiffDocument::new(
474 "repo".into(),
475 RefInfo { name: "main".into(), sha: "a".repeat(40) },
476 RefInfo { name: "feat".into(), sha: "b".repeat(40) },
477 "c".repeat(40),
478 "2026-07-03T00:00:00Z".into(),
479 vec![],
480 parsed(),
481 None,
482 );
483 let json = serde_json::to_string(&doc).unwrap();
484 let back: DiffDocument = serde_json::from_str(&json).unwrap();
485 assert_eq!(back.schema_version, SCHEMA_VERSION);
486 assert_eq!(back.files.len(), 5);
487 assert_eq!(back.additions(), doc.additions());
488 }
489}