Skip to main content

git_policy_cli/
replay_patch.rs

1//! What:
2//!  The unified-diff parser that replay's subsumption reads:
3//!       `git diff-tree -p` output over single-level trees named by decimal index,
4//!       split into hunks per entry name.
5//! Why:
6//!  Subsumption decides from hunk lines and their markers;
7//!       the parser keeps every byte exact,
8//!       so a hunk line round-trips to the blob bytes it came from.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! parseIndexedPatch(text: string): ReadonlyMap<string, readonly PatchHunk[]>
13//! ```
14
15/// `BTreeMap` keeps the parsed sections in a deterministic order for tests.
16use std::collections::BTreeMap;
17/// Owned byte strings for lines.
18use std::fmt;
19
20/// What:
21///  One patch line:
22///       its marker (`b' '`, `b'-'`, `b'+'`) and its bytes.
23/// Why:
24///   The reverse check and the containment check both read the markers.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type PatchLine = { op: ' ' | '-' | '+'; text: string };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct PatchLine {
32    /// Context, removal, or addition.
33    pub op: u8,
34    /// Line bytes with their newline,
35    ///  without it for an incomplete last line.
36    pub text: Vec<u8>,
37}
38
39/// What:
40///  One unified-diff hunk:
41///       its first old and new line and its lines in order.
42/// Why:
43///   A zero-based start is derived from `oldStart` by the callers that need it.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type PatchHunk = { oldStart: number; newStart: number; lines: readonly PatchLine[] };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct PatchHunk {
51    /// First line of the old side, `0` for an empty old side.
52    pub old_start: i64,
53    /// First line of the new side, `0` for an empty new side.
54    pub new_start: i64,
55    /// Hunk lines in order.
56    pub lines: Vec<PatchLine>,
57}
58
59/// What:
60///  Patch output that does not have the shape `git diff-tree -p` writes.
61/// Why:
62///   Callers fail the replay rather than guess at a truncated or foreign patch.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// class ReplayPatchFormatError extends Error {}
67/// ```
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct ReplayPatchFormatError(pub String);
70
71impl fmt::Display for ReplayPatchFormatError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        return write!(formatter, "{}", self.0);
74    }
75}
76
77/// Section header of one file in `diff-tree -p` output.
78const SECTION_HEADER: &[u8] = b"diff --git a/";
79/// Hunk header prefix.
80const HUNK_HEADER: &[u8] = b"@@ -";
81/// Marker of a `\ No newline at end of file` line.
82const INCOMPLETE_MARKER: u8 = b'\\';
83
84/// What:
85///  Split bytes into lines,
86///       each keeping its newline;
87///       an unterminated last line stays as it is.
88/// Why:
89///   Hunk bodies and blob contents are compared line by line,
90///       newline included, the way Git's `xdiff` compares them.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// splitKeepingNewlines('a\nb'); // ['a\n', 'b']
95/// ```
96pub fn split_keeping_newlines(text: &[u8]) -> Vec<Vec<u8>> {
97    // `mut` allows collecting lines while walking the bytes once.
98    let mut lines: Vec<Vec<u8>> = Vec::new();
99    let mut start: usize = 0;
100    while start < text.len() {
101        let stop: usize = match text[start..].iter().position(|byte| { return *byte == b'\n'; }) {
102            Some(offset) => start + offset + 1,
103            None => text.len(),
104        };
105        lines.push(text[start..stop].to_vec());
106        start = stop;
107    }
108    return lines;
109}
110
111/// What:
112///  Parse one non-negative decimal number,
113///       rejecting anything but ASCII digits.
114/// Why:
115///   Git prints only digits in hunk ranges;
116///       any other byte means the patch is not one Git wrote.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// Number(text) // must be a safe integer
121/// ```
122fn parse_number(text: &[u8]) -> Option<i64> {
123    if text.is_empty() || !text.iter().all(|byte| { return byte.is_ascii_digit(); }) {
124        return None;
125    }
126    // `to_str` is safe here: every byte is an ASCII digit.
127    let digits: &str = std::str::from_utf8(text).ok()?;
128    return digits.parse::<i64>().ok();
129}
130
131/// What:
132///  Parse one `-start[,count]` or `+start[,count]` range;
133///       a missing count is `1`.
134/// Why:
135///   Hunk headers name both sides this way,
136///       and the counts tell the body parser how many lines to read.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// parseRange('-3,2'); // { start: 3, count: 2 }
141/// ```
142fn parse_range(range: &[u8]) -> Result<(i64, i64), ReplayPatchFormatError> {
143    let body: &[u8] = &range[1..];
144    let (start_text, count_text): (&[u8], &[u8]) = match body.iter().position(|byte| { return *byte == b','; }) {
145        Some(comma) => (&body[..comma], &body[comma + 1..]),
146        None => (body, b"1"),
147    };
148    return match (parse_number(start_text), parse_number(count_text)) {
149        (Some(start), Some(count)) => Ok((start, count)),
150        _ => Err(ReplayPatchFormatError(format!(
151            "malformed hunk range {}",
152            String::from_utf8_lossy(range)
153        ))),
154    };
155}
156
157/// What:
158///  Drop the newline of the last parsed line,
159///       as a following `\ No newline at end of file` marker says.
160/// Why:
161///   An incomplete last line compares as a prefix of the file line that has the newline.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// markIncomplete(parsed); // throws when no line precedes the marker
166/// ```
167fn mark_incomplete(parsed: &mut [PatchLine]) -> Result<(), ReplayPatchFormatError> {
168    let last: &mut PatchLine = match parsed.last_mut() {
169        Some(line) => line,
170        None => {
171            return Err(ReplayPatchFormatError(String::from(
172                "an incomplete-line marker precedes every hunk line",
173            )));
174        }
175    };
176    if last.text.last() == Some(&b'\n') {
177        last.text.pop();
178    }
179    return Ok(());
180}
181
182/// What:
183///  Parse one hunk starting at its header line;
184///       returns the hunk and the index of the line after it.
185/// Why:
186///   Every body line is read once,
187///       counted down from the header's old and new counts,
188///       so a truncated or overlong hunk fails instead of swallowing the next section.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// parseHunk({ lines, start }); // { hunk, next }
193/// ```
194fn parse_hunk(lines: &[Vec<u8>], start: usize) -> Result<(PatchHunk, usize), ReplayPatchFormatError> {
195    let header: &[u8] = &lines[start];
196    let end: usize = match find_bytes(header, b" @@", 4) {
197        Some(found) => found,
198        None => {
199            return Err(ReplayPatchFormatError(format!(
200                "malformed hunk header {}",
201                String::from_utf8_lossy(header).trim_end()
202            )));
203        }
204    };
205    let ranges: &[u8] = &header[3..end];
206    let (old_range, new_range): (&[u8], &[u8]) = match ranges.iter().position(|byte| { return *byte == b' '; }) {
207        Some(space) => (&ranges[..space], &ranges[space + 1..]),
208        None => {
209            return Err(ReplayPatchFormatError(format!(
210                "malformed hunk header {}",
211                String::from_utf8_lossy(header).trim_end()
212            )));
213        }
214    };
215    let (old_start, old_count): (i64, i64) = parse_range(old_range)?;
216    let (new_start, new_count): (i64, i64) = parse_range(new_range)?;
217    // `mut` allows the counts and the cursor to count down while the body is read.
218    let mut parsed: Vec<PatchLine> = Vec::new();
219    let mut old_left: i64 = old_count;
220    let mut new_left: i64 = new_count;
221    let mut cursor: usize = start + 1;
222    while old_left > 0 || new_left > 0 {
223        let line: &[u8] = match lines.get(cursor) {
224            Some(found) => found,
225            None => {
226                return Err(ReplayPatchFormatError(format!(
227                    "hunk {} is truncated",
228                    String::from_utf8_lossy(header).trim_end()
229                )));
230            }
231        };
232        cursor += 1;
233        let marker: u8 = line_marker(line)?;
234        if marker == INCOMPLETE_MARKER {
235            mark_incomplete(&mut parsed)?;
236            continue;
237        }
238        if marker != b'+' {
239            old_left -= 1;
240        }
241        if marker != b'-' {
242            new_left -= 1;
243        }
244        let text: Vec<u8> = if line == b"\n" { b"\n".to_vec() } else { line[1..].to_vec() };
245        parsed.push(PatchLine { op: marker, text });
246    }
247    if lines.get(cursor).is_some_and(|line| { return line.starts_with(b"\\"); }) {
248        mark_incomplete(&mut parsed)?;
249        cursor += 1;
250    }
251    return Ok((
252        PatchHunk {
253            old_start,
254            new_start,
255            lines: parsed,
256        },
257        cursor,
258    ));
259}
260
261/// What:
262///  The marker of one hunk body line:
263///       a bare newline is an empty context line,
264///       and an incomplete-line marker is reported as `\`.
265/// Why:
266///   A line without one of the four markers cannot belong to a hunk body.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// lineMarker('\n'); // ' '
271/// ```
272fn line_marker(line: &[u8]) -> Result<u8, ReplayPatchFormatError> {
273    let first: u8 = if line == b"\n" { b' ' } else { line.first().copied().unwrap_or(0) };
274    if matches!(first, b' ' | b'-' | b'+' | b'\\') {
275        return Ok(first);
276    }
277    return Err(ReplayPatchFormatError(format!(
278        "hunk line {} has no diff marker",
279        String::from_utf8_lossy(line)
280    )));
281}
282
283/// What:
284///  The first index at or after `from` where `needle` starts in `haystack`.
285/// Why:
286///   The hunk header's range end is searched from after the `@@ -` prefix.
287///
288/// In TS you'd write (pseudocode):
289/// ```ts
290/// haystack.indexOf(needle, from)
291/// ```
292fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
293    if haystack.len() < needle.len() {
294        return None;
295    }
296    return (from..=haystack.len() - needle.len()).find(|index| { return &haystack[*index..*index + needle.len()] == needle; });
297}
298
299/// What:
300///  Parse `git diff-tree -p` output over index-named single-level trees
301///       into hunks per entry name;
302///       an entry without a section has no change.
303/// Why:
304///   Subsumption reads each candidate's landed and prepared hunks by its index name,
305///       so no patch header carries a user path.
306///
307/// In TS you'd write (pseudocode):
308/// ```ts
309/// parseIndexedPatch(text); // ReadonlyMap<string, readonly PatchHunk[]>
310/// ```
311pub fn parse_indexed_patch(
312    text: &[u8],
313) -> Result<BTreeMap<Vec<u8>, Vec<PatchHunk>>, ReplayPatchFormatError> {
314    let lines: Vec<Vec<u8>> = split_keeping_newlines(text);
315    // `mut` allows the sections and the cursor to advance.
316    let mut files: BTreeMap<Vec<u8>, Vec<PatchHunk>> = BTreeMap::new();
317    let mut section: Option<Vec<u8>> = None;
318    let mut hunks: Vec<PatchHunk> = Vec::new();
319    let mut cursor: usize = 0;
320    while cursor < lines.len() {
321        let line: &[u8] = &lines[cursor];
322        if line.starts_with(HUNK_HEADER) {
323            let (hunk, next): (PatchHunk, usize) = parse_hunk(&lines, cursor)?;
324            hunks.push(hunk);
325            cursor = next;
326            continue;
327        }
328        if line.starts_with(SECTION_HEADER) {
329            if let Some(name) = section.take() {
330                files.insert(name, std::mem::take(&mut hunks));
331            }
332            hunks.clear();
333            let rest: &[u8] = &line[SECTION_HEADER.len()..];
334            let name_end: usize = match rest.iter().position(|byte| { return *byte == b' '; }) {
335                Some(space) => space,
336                None => {
337                    return Err(ReplayPatchFormatError(format!(
338                        "section header {} has no destination",
339                        String::from_utf8_lossy(line).trim_end()
340                    )));
341                }
342            };
343            section = Some(rest[..name_end].to_vec());
344        }
345        cursor += 1;
346    }
347    if let Some(name) = section {
348        files.insert(name, hunks);
349    }
350    return Ok(files);
351}
352
353#[cfg(test)]
354#[path = "replay_patch_tests.rs"]
355mod tests;