use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LineKind {
Add,
Del,
Ctx,
Meta,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiffLine {
#[serde(rename = "type")]
pub kind: LineKind,
pub old_line: Option<u64>,
pub new_line: Option<u64>,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Hunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileDiff {
pub name: Option<String>,
pub binary: bool,
pub adds: u64,
pub dels: u64,
pub hunks: Vec<Hunk>,
}
fn parse_hunk_header(line: &str) -> Option<(u64, u64)> {
let rest = line.strip_prefix("@@ -")?;
let (old, rest) = rest.split_once(' ')?;
let new = rest.strip_prefix('+')?;
let (new, tail) = new.split_once(' ')?;
if !tail.starts_with("@@") {
return None;
}
let start = |s: &str| s.split(',').next().and_then(|n| n.parse::<u64>().ok());
Some((start(old)?, start(new)?))
}
#[must_use]
pub fn parse_patch(patch: &str) -> Vec<FileDiff> {
let mut files: Vec<FileDiff> = Vec::new();
let (mut old_n, mut new_n) = (0u64, 0u64);
for line in patch.split('\n') {
if let Some(rest) = line.strip_prefix("diff --git ") {
let mut f = FileDiff {
name: None,
binary: false,
adds: 0,
dels: 0,
hunks: Vec::new(),
};
if let Some(idx) = rest.rfind(" b/") {
if rest.starts_with("a/") {
f.name = Some(rest[idx + 3..].to_string());
}
}
files.push(f);
continue;
}
let Some(cur) = files.last_mut() else {
continue;
};
if let (true, Some(n)) = (cur.hunks.is_empty(), line.strip_prefix("+++ ")) {
if n != "/dev/null" {
cur.name = Some(n.strip_prefix("b/").unwrap_or(n).to_string());
}
continue;
}
if let (true, Some(n)) = (cur.hunks.is_empty(), line.strip_prefix("--- ")) {
if cur.name.is_none() && n != "/dev/null" {
cur.name = Some(n.strip_prefix("a/").unwrap_or(n).to_string());
}
continue;
}
if line.starts_with("Binary files") {
cur.binary = true;
continue;
}
if let Some((o, n)) = parse_hunk_header(line) {
old_n = o;
new_n = n;
cur.hunks.push(Hunk {
header: line.to_string(),
lines: Vec::new(),
});
continue;
}
let Some(hunk) = cur.hunks.last_mut() else {
continue;
};
if let Some(t) = line.strip_prefix('+') {
hunk.lines.push(DiffLine {
kind: LineKind::Add,
old_line: None,
new_line: Some(new_n),
text: t.to_string(),
});
new_n += 1;
cur.adds += 1;
} else if let Some(t) = line.strip_prefix('-') {
hunk.lines.push(DiffLine {
kind: LineKind::Del,
old_line: Some(old_n),
new_line: None,
text: t.to_string(),
});
old_n += 1;
cur.dels += 1;
} else if let Some(t) = line.strip_prefix(' ') {
hunk.lines.push(DiffLine {
kind: LineKind::Ctx,
old_line: Some(old_n),
new_line: Some(new_n),
text: t.to_string(),
});
old_n += 1;
new_n += 1;
} else if line.starts_with('\\') {
hunk.lines.push(DiffLine {
kind: LineKind::Meta,
old_line: None,
new_line: None,
text: line.to_string(),
});
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_modify_add_delete_and_binary() {
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";
let f = parse_patch(patch);
assert_eq!(f.len(), 4);
assert_eq!(f[0].name.as_deref(), Some("src/main.js"));
assert_eq!((f[0].adds, f[0].dels), (1, 1));
let l = &f[0].hunks[0].lines;
assert_eq!(l[1].kind, LineKind::Del);
assert_eq!((l[1].old_line, l[1].new_line), (Some(2), None));
assert_eq!(l[2].new_line, Some(2));
assert!(l
.iter()
.all(|x| x.kind != LineKind::Add || x.new_line.is_some()));
assert_eq!(f[1].name.as_deref(), Some("new.txt"));
assert_eq!(f[1].hunks[0].lines[1].kind, LineKind::Meta);
assert_eq!(f[2].name.as_deref(), Some("gone.txt"));
assert_eq!(f[2].dels, 1);
assert!(f[3].binary && f[3].hunks.is_empty());
}
#[test]
fn empty_patch_and_preamble_are_ignored() {
assert!(parse_patch("").is_empty());
assert!(parse_patch("commit abc\nAuthor: x\n").is_empty());
}
#[test]
fn json_shape_uses_type_and_camel_case() {
let f = parse_patch("diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n");
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v[0]["hunks"][0]["lines"][0]["type"], "del");
assert_eq!(v[0]["hunks"][0]["lines"][1]["newLine"], 1);
}
}