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 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub description: Option<NotesFile>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct NotesFile {
59 pub path: String,
63 pub text: String,
65 pub commits: Vec<String>,
68}
69
70impl DiffDocument {
71 #[allow(clippy::too_many_arguments)]
72 pub fn new(
73 repo: String, base: RefInfo, head: RefInfo, merge_base: String, generated_at: String, commits: Vec<Commit>,
74 files: Vec<FileDiff>, snapshots: Option<crate::snapshot::RangeSnapshots>, description: Option<NotesFile>,
75 ) -> Self {
76 Self {
77 schema_version: SCHEMA_VERSION,
78 tool: TOOL.to_string(),
79 repo,
80 base,
81 head,
82 merge_base,
83 generated_at,
84 commits,
85 files,
86 snapshots,
87 description,
88 }
89 }
90
91 pub fn additions(&self) -> u32 {
92 self.files.iter().map(|f| f.additions).sum()
93 }
94
95 pub fn deletions(&self) -> u32 {
96 self.files.iter().map(|f| f.deletions).sum()
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct Commit {
104 pub sha: String,
106 pub short: String,
108 pub author: String,
110 pub email: String,
113 pub date: String,
115 pub subject: String,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122pub enum FileStatus {
123 Added,
125 Deleted,
127 Modified,
129 Renamed,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct FileDiff {
138 pub old_path: Option<String>,
140 pub new_path: Option<String>,
142 pub status: FileStatus,
144 pub binary: bool,
146 pub hunks: Vec<Hunk>,
149 pub additions: u32,
152 pub deletions: u32,
154 pub notes: Vec<String>,
156}
157
158impl FileDiff {
159 pub fn display_path(&self) -> String {
161 match (self.status, &self.old_path, &self.new_path) {
162 (FileStatus::Renamed, Some(old), Some(new)) if old != new => format!("{old} → {new}"),
163 _ => self.anchor_path().to_string(),
164 }
165 }
166
167 pub fn anchor_path(&self) -> &str {
169 self.new_path.as_deref().or(self.old_path.as_deref()).unwrap_or("")
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct Hunk {
177 pub header: String,
179 pub lines: Vec<Line>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub enum Line {
191 Add {
193 new: u32,
195 text: String,
197 },
198 Del {
200 old: u32,
202 text: String,
204 },
205 Ctx {
207 old: u32,
209 new: u32,
211 text: String,
213 },
214 Meta {
217 text: String,
219 },
220}
221
222pub fn parse_unified_diff(text: &str) -> Vec<FileDiff> {
224 let mut files: Vec<FileDiff> = Vec::new();
225 let mut cur: Option<FileDiff> = None;
226 let mut in_hunk = false;
227 let mut old_no: u32 = 0;
228 let mut new_no: u32 = 0;
229
230 for line in text.lines() {
231 if let Some(rest) = line.strip_prefix("diff --git ") {
232 if let Some(done) = cur.take() {
233 files.push(done);
234 }
235 let (old, new) = split_ab(rest);
236 cur = Some(FileDiff {
237 old_path: Some(old),
238 new_path: Some(new),
239 status: FileStatus::Modified,
240 binary: false,
241 hunks: Vec::new(),
242 additions: 0,
243 deletions: 0,
244 notes: Vec::new(),
245 });
246 in_hunk = false;
247 continue;
248 }
249 let Some(f) = cur.as_mut() else { continue };
250
251 if in_hunk {
252 if let Some(body) = line.strip_prefix('+') {
255 f.hunks
256 .last_mut()
257 .expect("in_hunk implies a current hunk")
258 .lines
259 .push(Line::Add { new: new_no, text: body.to_string() });
260 new_no += 1;
261 f.additions += 1;
262 continue;
263 }
264 if let Some(body) = line.strip_prefix('-') {
265 f.hunks
266 .last_mut()
267 .expect("in_hunk implies a current hunk")
268 .lines
269 .push(Line::Del { old: old_no, text: body.to_string() });
270 old_no += 1;
271 f.deletions += 1;
272 continue;
273 }
274 if let Some(body) = line.strip_prefix(' ') {
275 f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Ctx {
276 old: old_no,
277 new: new_no,
278 text: body.to_string(),
279 });
280 old_no += 1;
281 new_no += 1;
282 continue;
283 }
284 if line.starts_with('\\') {
285 f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Meta { text: line.to_string() });
286 continue;
287 }
288 in_hunk = false; }
290
291 if let Some((o, n)) = parse_hunk_header(line) {
292 old_no = o;
293 new_no = n;
294 f.hunks.push(Hunk { header: line.to_string(), lines: Vec::new() });
295 in_hunk = true;
296 } else if line.starts_with("new file mode") {
297 f.status = FileStatus::Added;
298 f.old_path = None;
299 } else if line.starts_with("deleted file mode") {
300 f.status = FileStatus::Deleted;
301 f.new_path = None;
302 } else if let Some(p) = line.strip_prefix("rename from ") {
303 f.status = FileStatus::Renamed;
304 f.old_path = Some(p.to_string());
305 } else if let Some(p) = line.strip_prefix("rename to ") {
306 f.status = FileStatus::Renamed;
307 f.new_path = Some(p.to_string());
308 } else if line.starts_with("old mode ") || line.starts_with("new mode ") {
309 f.notes.push(line.to_string());
310 } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
311 f.binary = true;
312 } else if let Some(p) = line.strip_prefix("--- ") {
313 if let Some(stripped) = strip_prefix_path(p) {
314 f.old_path = Some(stripped);
315 } else if f.status != FileStatus::Added {
316 f.status = FileStatus::Added;
318 f.old_path = None;
319 }
320 } else if let Some(p) = line.strip_prefix("+++ ") {
321 if let Some(stripped) = strip_prefix_path(p) {
322 f.new_path = Some(stripped);
323 } else if f.status != FileStatus::Deleted {
324 f.status = FileStatus::Deleted;
325 f.new_path = None;
326 }
327 }
328 }
330 if let Some(done) = cur.take() {
331 files.push(done);
332 }
333 files
334}
335
336fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
338 let rest = line.strip_prefix("@@ -")?;
339 let (old_part, rest) = rest.split_once(" +")?;
340 let (new_part, _) = rest.split_once(" @@")?;
341 let old = old_part.split(',').next()?.parse().ok()?;
342 let new = new_part.split(',').next()?.parse().ok()?;
343 Some((old, new))
344}
345
346fn strip_prefix_path(path: &str) -> Option<String> {
348 if path == "/dev/null" {
349 return None;
350 }
351 let stripped = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
352 Some(stripped.to_string())
353}
354
355fn split_ab(rest: &str) -> (String, String) {
359 if let Some(stripped) = rest.strip_prefix('"') {
360 if let Some((old, new)) = stripped.split_once("\" \"") {
362 let old = old.strip_prefix("a/").unwrap_or(old);
363 let new = new.strip_prefix("b/").unwrap_or(new).trim_end_matches('"');
364 return (old.to_string(), new.to_string());
365 }
366 }
367 if let Some(idx) = rest.rfind(" b/") {
368 let old = strip_prefix_path(&rest[..idx]).unwrap_or_default();
369 let new = rest[idx + 3..].to_string();
370 return (old, new);
371 }
372 (rest.to_string(), rest.to_string())
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 const SAMPLE: &str = "\
380diff --git a/hello.py b/hello.py
381index 1111111..2222222 100644
382--- a/hello.py
383+++ b/hello.py
384@@ -1,2 +1,4 @@
385 def hello():
386- return 'hi'
387+ return 'hello'
388+
389+# trailing
390diff --git a/gone.txt b/gone.txt
391deleted file mode 100644
392index 3333333..0000000
393--- a/gone.txt
394+++ /dev/null
395@@ -1 +0,0 @@
396-obsolete
397\\ No newline at end of file
398diff --git a/old name.txt b/new name.txt
399similarity index 90%
400rename from old name.txt
401rename to new name.txt
402diff --git a/fresh.md b/fresh.md
403new file mode 100644
404index 0000000..4444444
405--- /dev/null
406+++ b/fresh.md
407@@ -0,0 +1,2 @@
408+# Fresh
409+body
410diff --git a/blob.bin b/blob.bin
411index 5555555..6666666 100644
412Binary files a/blob.bin and b/blob.bin differ
413";
414
415 fn parsed() -> Vec<FileDiff> {
416 parse_unified_diff(SAMPLE)
417 }
418
419 #[test]
420 fn parses_all_files() {
421 assert_eq!(parsed().len(), 5);
422 }
423
424 #[test]
425 fn modified_file_lines_and_numbers() {
426 let files = parsed();
427 let f = &files[0];
428 assert_eq!(f.status, FileStatus::Modified);
429 assert_eq!((f.additions, f.deletions), (3, 1));
430 let lines = &f.hunks[0].lines;
431 assert_eq!(lines[0], Line::Ctx { old: 1, new: 1, text: "def hello():".into() });
432 assert_eq!(lines[1], Line::Del { old: 2, text: " return 'hi'".into() });
433 assert_eq!(lines[2], Line::Add { new: 2, text: " return 'hello'".into() });
434 assert_eq!(lines[4], Line::Add { new: 4, text: "# trailing".into() });
435 }
436
437 #[test]
438 fn deleted_file() {
439 let files = parsed();
440 let f = &files[1];
441 assert_eq!(f.status, FileStatus::Deleted);
442 assert_eq!(f.new_path, None);
443 assert_eq!(f.anchor_path(), "gone.txt");
444 assert!(matches!(f.hunks[0].lines.last(), Some(Line::Meta { .. })));
445 }
446
447 #[test]
448 fn rename_with_spaces_in_paths() {
449 let files = parsed();
450 let f = &files[2];
451 assert_eq!(f.status, FileStatus::Renamed);
452 assert_eq!(f.old_path.as_deref(), Some("old name.txt"));
453 assert_eq!(f.new_path.as_deref(), Some("new name.txt"));
454 assert_eq!(f.display_path(), "old name.txt → new name.txt");
455 assert!(f.hunks.is_empty());
456 }
457
458 #[test]
459 fn added_file() {
460 let files = parsed();
461 let f = &files[3];
462 assert_eq!(f.status, FileStatus::Added);
463 assert_eq!(f.old_path, None);
464 assert_eq!(f.additions, 2);
465 }
466
467 #[test]
468 fn binary_file() {
469 let files = parsed();
470 assert!(files[4].binary);
471 assert!(files[4].hunks.is_empty());
472 }
473
474 #[test]
475 fn hunk_header_forms() {
476 assert_eq!(parse_hunk_header("@@ -1,2 +3,4 @@"), Some((1, 3)));
477 assert_eq!(parse_hunk_header("@@ -1 +0,0 @@"), Some((1, 0)));
478 assert_eq!(parse_hunk_header("@@ -10,5 +12,7 @@ fn ctx()"), Some((10, 12)));
479 assert_eq!(parse_hunk_header("not a hunk"), None);
480 }
481
482 #[test]
483 fn lines_encode_as_single_key_unions() {
484 let add = Line::Add { new: 2, text: "x".into() };
485 assert_eq!(serde_json::to_value(&add).unwrap(), serde_json::json!({ "Add": { "new": 2, "text": "x" } }));
486 assert_eq!(serde_json::to_value(FileStatus::Renamed).unwrap(), serde_json::json!("Renamed"));
487 }
488
489 #[test]
490 fn unknown_fields_are_rejected() {
491 let bad = r#"{ "Add": { "new": 2, "text": "x", "sneaky": true } }"#;
492 assert!(serde_json::from_str::<Line>(bad).is_err());
493 }
494
495 #[test]
496 fn document_roundtrips_through_json() {
497 let doc = DiffDocument::new(
498 "repo".into(),
499 RefInfo { name: "main".into(), sha: "a".repeat(40) },
500 RefInfo { name: "feat".into(), sha: "b".repeat(40) },
501 "c".repeat(40),
502 "2026-07-03T00:00:00Z".into(),
503 vec![],
504 parsed(),
505 None,
506 Some(NotesFile {
507 path: "PR-DESCRIPTION.md".into(),
508 text: "# Title\n\nBody.".into(),
509 commits: vec!["d".repeat(40)],
510 }),
511 );
512 let json = serde_json::to_string(&doc).unwrap();
513 let back: DiffDocument = serde_json::from_str(&json).unwrap();
514 assert_eq!(back.schema_version, SCHEMA_VERSION);
515 assert_eq!(back.files.len(), 5);
516 assert_eq!(back.additions(), doc.additions());
517 assert_eq!(back.description.unwrap().path, "PR-DESCRIPTION.md");
518 let none = DiffDocument::new(
520 "repo".into(),
521 RefInfo { name: "main".into(), sha: "a".repeat(40) },
522 RefInfo { name: "feat".into(), sha: "b".repeat(40) },
523 "c".repeat(40),
524 "2026-07-03T00:00:00Z".into(),
525 vec![],
526 vec![],
527 None,
528 None,
529 );
530 assert!(!serde_json::to_string(&none).unwrap().contains("description"));
531 }
532}