1use serde::Serialize;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum LineKind {
15 Add,
17 Del,
19 Ctx,
21 Meta,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct DiffLine {
29 #[serde(rename = "type")]
31 pub kind: LineKind,
32 pub old_line: Option<u64>,
34 pub new_line: Option<u64>,
36 pub text: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct Hunk {
43 pub header: String,
45 pub lines: Vec<DiffLine>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct FileDiff {
52 pub name: Option<String>,
54 pub binary: bool,
56 pub adds: u64,
58 pub dels: u64,
60 pub hunks: Vec<Hunk>,
62}
63
64fn parse_hunk_header(line: &str) -> Option<(u64, u64)> {
65 let rest = line.strip_prefix("@@ -")?;
66 let (old, rest) = rest.split_once(' ')?;
67 let new = rest.strip_prefix('+')?;
68 let (new, tail) = new.split_once(' ')?;
69 if !tail.starts_with("@@") {
70 return None;
71 }
72 let start = |s: &str| s.split(',').next().and_then(|n| n.parse::<u64>().ok());
73 Some((start(old)?, start(new)?))
74}
75
76#[must_use]
92pub fn parse_patch(patch: &str) -> Vec<FileDiff> {
93 let mut files: Vec<FileDiff> = Vec::new();
94 let (mut old_n, mut new_n) = (0u64, 0u64);
95 for line in patch.split('\n') {
96 if let Some(rest) = line.strip_prefix("diff --git ") {
97 let mut f = FileDiff {
98 name: None,
99 binary: false,
100 adds: 0,
101 dels: 0,
102 hunks: Vec::new(),
103 };
104 if let Some(idx) = rest.rfind(" b/") {
106 if rest.starts_with("a/") {
107 f.name = Some(rest[idx + 3..].to_string());
108 }
109 }
110 files.push(f);
111 continue;
112 }
113 let Some(cur) = files.last_mut() else {
114 continue;
115 };
116 if let (true, Some(n)) = (cur.hunks.is_empty(), line.strip_prefix("+++ ")) {
117 if n != "/dev/null" {
118 cur.name = Some(n.strip_prefix("b/").unwrap_or(n).to_string());
119 }
120 continue;
121 }
122 if let (true, Some(n)) = (cur.hunks.is_empty(), line.strip_prefix("--- ")) {
123 if cur.name.is_none() && n != "/dev/null" {
124 cur.name = Some(n.strip_prefix("a/").unwrap_or(n).to_string());
125 }
126 continue;
127 }
128 if line.starts_with("Binary files") {
129 cur.binary = true;
130 continue;
131 }
132 if let Some((o, n)) = parse_hunk_header(line) {
133 old_n = o;
134 new_n = n;
135 cur.hunks.push(Hunk {
136 header: line.to_string(),
137 lines: Vec::new(),
138 });
139 continue;
140 }
141 let Some(hunk) = cur.hunks.last_mut() else {
142 continue;
143 };
144 if let Some(t) = line.strip_prefix('+') {
145 hunk.lines.push(DiffLine {
146 kind: LineKind::Add,
147 old_line: None,
148 new_line: Some(new_n),
149 text: t.to_string(),
150 });
151 new_n += 1;
152 cur.adds += 1;
153 } else if let Some(t) = line.strip_prefix('-') {
154 hunk.lines.push(DiffLine {
155 kind: LineKind::Del,
156 old_line: Some(old_n),
157 new_line: None,
158 text: t.to_string(),
159 });
160 old_n += 1;
161 cur.dels += 1;
162 } else if let Some(t) = line.strip_prefix(' ') {
163 hunk.lines.push(DiffLine {
164 kind: LineKind::Ctx,
165 old_line: Some(old_n),
166 new_line: Some(new_n),
167 text: t.to_string(),
168 });
169 old_n += 1;
170 new_n += 1;
171 } else if line.starts_with('\\') {
172 hunk.lines.push(DiffLine {
173 kind: LineKind::Meta,
174 old_line: None,
175 new_line: None,
176 text: line.to_string(),
177 });
178 }
179 }
180 files
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn parses_modify_add_delete_and_binary() {
189 let patch = "diff --git a/src/main.js b/src/main.js\nindex 1..2 100644\n--- a/src/main.js\n+++ b/src/main.js\n@@ -1,3 +1,3 @@\n function main() {\n- return 1;\n+ return 2;\n }\ndiff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+hi\n\\ No newline at end of file\ndiff --git a/gone.txt b/gone.txt\ndeleted file mode 100644\n--- a/gone.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-bye\ndiff --git a/b.bin b/b.bin\nBinary files a/b.bin and b/b.bin differ\n";
190 let f = parse_patch(patch);
191 assert_eq!(f.len(), 4);
192 assert_eq!(f[0].name.as_deref(), Some("src/main.js"));
193 assert_eq!((f[0].adds, f[0].dels), (1, 1));
194 let l = &f[0].hunks[0].lines;
195 assert_eq!(l[1].kind, LineKind::Del);
196 assert_eq!((l[1].old_line, l[1].new_line), (Some(2), None));
197 assert_eq!(l[2].new_line, Some(2));
198 assert!(l
199 .iter()
200 .all(|x| x.kind != LineKind::Add || x.new_line.is_some()));
201 assert_eq!(f[1].name.as_deref(), Some("new.txt"));
202 assert_eq!(f[1].hunks[0].lines[1].kind, LineKind::Meta);
203 assert_eq!(f[2].name.as_deref(), Some("gone.txt"));
204 assert_eq!(f[2].dels, 1);
205 assert!(f[3].binary && f[3].hunks.is_empty());
206 }
207
208 #[test]
209 fn empty_patch_and_preamble_are_ignored() {
210 assert!(parse_patch("").is_empty());
211 assert!(parse_patch("commit abc\nAuthor: x\n").is_empty());
212 }
213
214 #[test]
215 fn json_shape_uses_type_and_camel_case() {
216 let f = parse_patch("diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n");
217 let v = serde_json::to_value(&f).unwrap();
218 assert_eq!(v[0]["hunks"][0]["lines"][0]["type"], "del");
219 assert_eq!(v[0]["hunks"][0]["lines"][1]["newLine"], 1);
220 }
221}