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