Skip to main content

grit_lib/apply/
blob_apply.rs

1//! Apply parsed [`FilePatch`] hunks to in-memory blob bytes (no I/O).
2
3use std::borrow::Cow;
4
5use super::{BinaryPatchPayload, FilePatch, Hunk, HunkLine};
6use crate::error::{ApplyError, Error};
7
8/// Options for [`apply_patch_to_blob`].
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct BlobApplyOptions {
11    /// When true, allow `Binary files … differ` markers with no payload (result unchanged).
12    pub allow_binary_marker_only: bool,
13    /// When true, apply hunks in reverse (`git apply -R` semantics).
14    pub reverse: bool,
15}
16
17/// Apply one file patch to `base` blob bytes and return the post-image.
18///
19/// Binary patches use [`super::materialize_binary_patch_forward`]. Text patches apply
20/// unified-diff hunks in reverse line order so hunk headers stay anchored to the preimage.
21pub fn apply_patch_to_blob(
22    base: &[u8],
23    patch: &FilePatch,
24    opts: &BlobApplyOptions,
25) -> std::result::Result<Vec<u8>, ApplyError> {
26    if patch.is_deleted {
27        let path = patch.effective_path().unwrap_or("<unknown>").to_string();
28        if opts.reverse {
29            return super::apply_hunks_to_blob(
30                base,
31                &patch.hunks,
32                &super::ApplyOptions { reverse: true },
33            )
34            .map_err(|e| match e {
35                ApplyError::PatchFailed { path, line } => ApplyError::PatchFailed { path, line },
36                other => other,
37            });
38        }
39        let result = apply_hunks_to_blob(base, &patch.hunks, &path)?;
40        if !result.is_empty() {
41            return Err(ApplyError::Failed {
42                path,
43                message: "deletion patch did not remove all index content".to_string(),
44            });
45        }
46        return Ok(Vec::new());
47    }
48    if let Some(payload) = patch.binary_patch.as_ref() {
49        return materialize_binary(base, payload);
50    }
51    if patch.is_binary {
52        if opts.allow_binary_marker_only {
53            return Ok(base.to_vec());
54        }
55        let path = patch.effective_path().unwrap_or("<unknown>").to_string();
56        return Err(ApplyError::BinaryDataMissing { path });
57    }
58    if patch.hunks.is_empty() {
59        return Ok(base.to_vec());
60    }
61    let path = patch.effective_path().unwrap_or("<unknown>").to_string();
62    if opts.reverse {
63        return super::apply_hunks_to_blob(
64            base,
65            &patch.hunks,
66            &super::ApplyOptions { reverse: true },
67        )
68        .map_err(|e| match e {
69            ApplyError::PatchFailed { path, line } => ApplyError::PatchFailed { path, line },
70            other => other,
71        });
72    }
73    apply_hunks_to_blob(base, &patch.hunks, &path)
74}
75
76fn materialize_binary(
77    base: &[u8],
78    payload: &BinaryPatchPayload,
79) -> std::result::Result<Vec<u8>, ApplyError> {
80    super::materialize_binary_patch_forward(payload, base).map_err(|e| match e {
81        Error::Apply(a) => a,
82        other => ApplyError::Failed {
83            path: String::new(),
84            message: other.to_string(),
85        },
86    })
87}
88
89/// One line of the image being patched: borrowed from the preimage or a hunk where possible.
90type Line<'a> = Cow<'a, [u8]>;
91
92fn apply_hunks_to_blob(
93    base: &[u8],
94    hunks: &[Hunk],
95    path: &str,
96) -> std::result::Result<Vec<u8>, ApplyError> {
97    let mut lines = split_blob_lines(base);
98    let mut ordered: Vec<&Hunk> = hunks.iter().collect();
99    ordered.sort_by(|a, b| {
100        b.old_start
101            .cmp(&a.old_start)
102            .then(b.old_count.cmp(&a.old_count))
103    });
104
105    for hunk in ordered {
106        apply_one_hunk(&mut lines, hunk, path)?;
107    }
108    Ok(join_blob_lines(&lines))
109}
110
111fn apply_one_hunk<'a>(
112    lines: &mut Vec<Line<'a>>,
113    hunk: &'a Hunk,
114    path: &str,
115) -> std::result::Result<(), ApplyError> {
116    let (old_side, new_side) = hunk_old_new(hunk);
117    if old_side.is_empty() {
118        let insert_at = if hunk.old_start == 0 {
119            0
120        } else {
121            hunk.old_start.saturating_sub(1).min(lines.len())
122        };
123        lines.splice(insert_at..insert_at, new_side);
124        return Ok(());
125    }
126
127    let pos =
128        find_hunk_match(lines, &old_side, hunk.old_start.saturating_sub(1)).ok_or_else(|| {
129            ApplyError::PatchFailed {
130                path: path.to_string(),
131                line: hunk.first_body_line,
132            }
133        })?;
134    lines.splice(pos..pos.saturating_add(old_side.len()), new_side);
135    Ok(())
136}
137
138fn hunk_old_new(hunk: &Hunk) -> (Vec<Line<'_>>, Vec<Line<'_>>) {
139    let mut old_side = Vec::new();
140    let mut new_side = Vec::new();
141    let body = &hunk.lines;
142    for (idx, line) in body.iter().enumerate() {
143        if matches!(line, HunkLine::NoNewline) {
144            continue;
145        }
146        let with_newline = !matches!(body.get(idx + 1), Some(HunkLine::NoNewline));
147        match line {
148            HunkLine::Context(text) => {
149                let bytes = hunk_line_bytes(text, with_newline);
150                old_side.push(bytes.clone());
151                new_side.push(bytes);
152            }
153            HunkLine::Remove(text) => old_side.push(hunk_line_bytes(text, with_newline)),
154            HunkLine::Add(text) => new_side.push(hunk_line_bytes(text, with_newline)),
155            HunkLine::NoNewline => {}
156        }
157    }
158    (old_side, new_side)
159}
160
161fn hunk_line_bytes(text: &str, with_newline: bool) -> Line<'_> {
162    let bytes = text.as_bytes();
163    if !with_newline || bytes.last() == Some(&b'\n') {
164        return Cow::Borrowed(bytes);
165    }
166    let mut owned = Vec::with_capacity(bytes.len() + 1);
167    owned.extend_from_slice(bytes);
168    owned.push(b'\n');
169    Cow::Owned(owned)
170}
171
172fn find_hunk_match(lines: &[Line<'_>], old_side: &[Line<'_>], hint: usize) -> Option<usize> {
173    if old_side.is_empty() {
174        return Some(hint.min(lines.len()));
175    }
176    let search_from = hint.min(lines.len().saturating_sub(old_side.len()));
177    if lines
178        .get(search_from..search_from.saturating_add(old_side.len()))
179        .is_some_and(|window| window == old_side)
180    {
181        return Some(search_from);
182    }
183    for pos in 0..=lines.len().saturating_sub(old_side.len()) {
184        if lines[pos..pos + old_side.len()] == *old_side {
185            return Some(pos);
186        }
187    }
188    None
189}
190
191fn split_blob_lines(data: &[u8]) -> Vec<Line<'_>> {
192    data.split_inclusive(|&b| b == b'\n')
193        .map(Cow::Borrowed)
194        .collect()
195}
196
197fn join_blob_lines(lines: &[Line<'_>]) -> Vec<u8> {
198    let mut out = Vec::with_capacity(lines.iter().map(|l| l.len()).sum());
199    for line in lines {
200        out.extend_from_slice(line);
201    }
202    out
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::apply::parse_patch;
209
210    #[test]
211    fn blob_apply_single_line_change() {
212        let patch_text = "\
213diff --git a/a.txt b/a.txt
214--- a/a.txt
215+++ b/a.txt
216@@ -1 +1 @@
217-one
218+two
219";
220        let patches = parse_patch(patch_text, 1, "p", false, None).expect("parse");
221        let got = apply_patch_to_blob(b"one\n", &patches[0], &BlobApplyOptions::default())
222            .expect("apply");
223        assert_eq!(got, b"two\n");
224    }
225
226    #[test]
227    fn blob_apply_no_newline_preimage() {
228        let patch_text = "\
229diff --git a/f.txt b/f.txt
230--- a/f.txt
231+++ b/f.txt
232@@ -1 +1 @@
233-old
234\\ No newline at end of file
235+new
236";
237        let patches = parse_patch(patch_text, 1, "p", false, None).expect("parse");
238        let got =
239            apply_patch_to_blob(b"old", &patches[0], &BlobApplyOptions::default()).expect("apply");
240        assert_eq!(got, b"new\n");
241    }
242
243    #[test]
244    fn blob_apply_hunks_that_grow_and_shrink_the_image() {
245        let base = b"a\nb\nc\nd\ne\nf\ng\nh\n";
246        let patch_text = "\
247diff --git a/f.txt b/f.txt
248--- a/f.txt
249+++ b/f.txt
250@@ -1,2 +1,3 @@
251 a
252+a2
253 b
254@@ -6,3 +7,2 @@
255 f
256-g
257 h
258";
259        let patches = parse_patch(patch_text, 1, "p", false, None).expect("parse");
260        let got =
261            apply_patch_to_blob(base, &patches[0], &BlobApplyOptions::default()).expect("apply");
262        assert_eq!(got, b"a\na2\nb\nc\nd\ne\nf\nh\n");
263    }
264
265    #[test]
266    fn blob_apply_adds_missing_final_newline() {
267        let patch_text = "\
268diff --git a/f.txt b/f.txt
269--- a/f.txt
270+++ b/f.txt
271@@ -1,2 +1,2 @@
272 keep
273-tail
274\\ No newline at end of file
275+tail
276";
277        let patches = parse_patch(patch_text, 1, "p", false, None).expect("parse");
278        let got = apply_patch_to_blob(b"keep\ntail", &patches[0], &BlobApplyOptions::default())
279            .expect("apply");
280        assert_eq!(got, b"keep\ntail\n");
281    }
282
283    #[test]
284    fn blob_apply_finds_hunk_away_from_its_header_line() {
285        let patch_text = "\
286diff --git a/f.txt b/f.txt
287--- a/f.txt
288+++ b/f.txt
289@@ -1 +1 @@
290-x
291+y
292";
293        let patches = parse_patch(patch_text, 1, "p", false, None).expect("parse");
294        let got = apply_patch_to_blob(b"p\nq\nx\n", &patches[0], &BlobApplyOptions::default())
295            .expect("apply");
296        assert_eq!(got, b"p\nq\ny\n");
297        let err = apply_patch_to_blob(b"p\nq\n", &patches[0], &BlobApplyOptions::default())
298            .expect_err("no match");
299        assert!(matches!(err, ApplyError::PatchFailed { .. }), "{err:?}");
300    }
301
302    #[test]
303    fn split_and_join_round_trip_without_copying_lines() {
304        for data in [
305            &b""[..],
306            b"\n",
307            b"one",
308            b"one\ntwo",
309            b"one\ntwo\n",
310            b"\n\nx\n",
311        ] {
312            let lines = split_blob_lines(data);
313            assert!(lines.iter().all(|l| matches!(l, Cow::Borrowed(_))));
314            assert_eq!(join_blob_lines(&lines), data);
315        }
316        assert_eq!(split_blob_lines(b"a\nb").len(), 2);
317    }
318}