Skip to main content

solid_pod_rs_forge/
diff.rs

1//! Structured unified-diff parser: one parse feeds the JSON API's
2//! `files[] -> hunks[] -> lines[]` shape (and, later, the compare and pull
3//! request views).
4//!
5//! Follows the JSS `forge` `parsePatch` contract by name: each file is
6//! `{name, binary, adds, dels, hunks}`; each hunk `{header, lines}`; each line
7//! `{type: add|del|ctx|meta, oldLine, newLine, text}`.
8
9use serde::Serialize;
10
11/// Kind of a diff line.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum LineKind {
15    /// Added line (`+`).
16    Add,
17    /// Removed line (`-`).
18    Del,
19    /// Context line (` `).
20    Ctx,
21    /// `\ No newline at end of file` style marker.
22    Meta,
23}
24
25/// One line of a hunk.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct DiffLine {
29    /// Line kind (`type` in the JSON).
30    #[serde(rename = "type")]
31    pub kind: LineKind,
32    /// Line number in the old file, when the line exists there.
33    pub old_line: Option<u64>,
34    /// Line number in the new file, when the line exists there.
35    pub new_line: Option<u64>,
36    /// Text without the leading diff sign.
37    pub text: String,
38}
39
40/// A `@@ -a,b +c,d @@` hunk.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct Hunk {
43    /// The raw `@@` header line.
44    pub header: String,
45    /// The hunk's lines.
46    pub lines: Vec<DiffLine>,
47}
48
49/// One file of a patch.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct FileDiff {
52    /// New-side file name (old-side for deletions).
53    pub name: Option<String>,
54    /// `true` for a `Binary files … differ` entry.
55    pub binary: bool,
56    /// Added line count.
57    pub adds: u64,
58    /// Removed line count.
59    pub dels: u64,
60    /// The hunks.
61    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/// Parse a `git show --patch` / `git diff` unified patch.
77///
78/// # Examples
79///
80/// ```
81/// use solid_pod_rs_forge::diff::{parse_patch, LineKind};
82///
83/// let patch = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n";
84/// let files = parse_patch(patch);
85/// assert_eq!(files[0].name.as_deref(), Some("f.txt"));
86/// assert_eq!((files[0].adds, files[0].dels), (1, 1));
87/// let lines = &files[0].hunks[0].lines;
88/// assert_eq!(lines[2].kind, LineKind::Add);
89/// assert_eq!(lines[2].new_line, Some(2));
90/// ```
91#[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            // `a/<x> b/<y>`: the new-side name is everything after the last " b/".
105            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}