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;