Skip to main content

vtcode_diff/
reverse.rs

1//! Strict per-file unified-diff splitting and exact reverse application.
2//!
3//! Rendering tolerates lossy previews (truncated bodies, omitted rows,
4//! missing-newline markers). Reversal cannot: every byte of the pre-image
5//! must be recoverable from the recorded hunks, so this module rejects
6//! anything it cannot reproduce exactly. Callers that only display diffs
7//! should keep using [`DiffDocument::from_unified`].
8
9use super::compute::split_line_slices;
10use super::parse::{parse_hunk_range, trim_line_ending};
11use super::{DiffDocument, DiffHunk, DiffLineKind, ParseDiffError};
12
13/// Refusal bound for reverse matching: inputs above this size are rejected
14/// rather than matched slowly on a request path.
15pub const MAX_REVERSE_FILE_BYTES: usize = 8 * 1024 * 1024;
16
17/// One file's hunks from a multi-file unified diff.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UnifiedFile {
20    /// Old-side path, or `None` for `/dev/null` (added file).
21    pub old_path: Option<String>,
22    /// New-side path, or `None` for `/dev/null` (deleted file).
23    pub new_path: Option<String>,
24    /// Hunks in file order; never empty.
25    pub hunks: Vec<DiffHunk>,
26}
27
28/// Exact reversal failure. Every variant refuses the whole file: partial
29/// reversal would silently corrupt content the evidence cannot reproduce.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ReverseApplyError {
32    /// No hunks to reverse.
33    EmptyDiff,
34    /// Current content exceeds [`MAX_REVERSE_FILE_BYTES`].
35    TooLarge,
36    /// Hunk has no new-side lines, so no content anchors its position.
37    EmptyNewSide {
38        /// Zero-based hunk index.
39        hunk: usize,
40    },
41    /// The hunk's new-side block does not occur in the current content:
42    /// the file changed since the recorded edit.
43    NoMatch {
44        /// Zero-based hunk index.
45        hunk: usize,
46    },
47    /// The hunk's new-side block occurs more than once: reversing would
48    /// guess which occurrence the recorded edit produced.
49    Ambiguous {
50        /// Zero-based hunk index.
51        hunk: usize,
52    },
53    /// Matched spans overlap or run out of order.
54    OverlappingSpans,
55}
56
57impl std::fmt::Display for ReverseApplyError {
58    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::EmptyDiff => formatter.write_str("no hunks to reverse"),
61            Self::TooLarge => formatter.write_str("file exceeds the reversal size bound"),
62            Self::EmptyNewSide { hunk } => write!(formatter, "hunk {hunk} has no new-side lines"),
63            Self::NoMatch { hunk } => {
64                write!(formatter, "hunk {hunk} does not match the current content")
65            }
66            Self::Ambiguous { hunk } => {
67                write!(formatter, "hunk {hunk} matches the current content more than once")
68            }
69            Self::OverlappingSpans => formatter.write_str("hunk matches overlap or run out of order"),
70        }
71    }
72}
73
74impl std::error::Error for ReverseApplyError {}
75
76/// Splits multi-file unified text into per-file hunk groups.
77///
78/// # Requires
79///
80/// - `input` is UTF-8 unified-diff text.
81///
82/// # Ensures
83///
84/// - `Ok(files)` holds one entry per file section, each with at least one
85///   hunk validated by [`DiffDocument::from_unified`].
86/// - Returns `Err` for lossy input a renderer would tolerate: `\ No newline`
87///   markers, omitted-row markers, sections without hunks, and unparseable
88///   quoted paths. Total (terminating): single pass plus one bounded parse
89///   per section.
90pub fn split_unified_files(input: &str) -> Result<Vec<UnifiedFile>, ParseDiffError> {
91    for raw in split_line_slices(input) {
92        let trimmed = trim_line_ending(raw);
93        if trimmed == "\\ No newline at end of file" {
94            return Err(ParseDiffError::new("missing-newline marker cannot be reversed exactly"));
95        }
96        // Body-prefixed lines are file content, not markers: a context
97        // line may legitimately read `... 3 lines omitted ...`.
98        let body_prefixed = matches!(trimmed.as_bytes().first(), Some(b' ' | b'+' | b'-'));
99        if !body_prefixed && super::parse::parse_omitted_line_count(trimmed).is_some() {
100            return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
101        }
102    }
103
104    // Hunk-body tracking mirrors `DiffDocument::from_unified`: a `--- ` line
105    // is a file header only outside a hunk body, since a deleted line whose
106    // content starts with `-- ` renders identically (likewise `+++ ` for an
107    // added `++ ` line). Headers are recorded during the split so body
108    // lines can never be misread as paths.
109    let mut sections: Vec<Section> = Vec::new();
110    let mut section_has_hunk = false;
111    let mut current = Section::default();
112    let mut expected_old = 0usize;
113    let mut expected_new = 0usize;
114    let mut seen_old = 0usize;
115    let mut seen_new = 0usize;
116    let mut in_hunk = false;
117
118    for raw in split_line_slices(input) {
119        let hunk_complete = !in_hunk || (seen_old >= expected_old && seen_new >= expected_new);
120        let trimmed = trim_line_ending(raw);
121        if trimmed.starts_with("@@") {
122            let (_, old_count, _, new_count) =
123                parse_hunk_range(trimmed).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
124            in_hunk = true;
125            section_has_hunk = true;
126            expected_old = old_count;
127            expected_new = new_count;
128            seen_old = 0;
129            seen_new = 0;
130            current.lines.push(raw);
131            continue;
132        }
133        if trimmed.starts_with("diff --git ") {
134            // Body lines always carry a ` `/`+`/`-` prefix, so a column-zero
135            // `diff --git` header is always a real boundary.
136            if !current.lines.is_empty() {
137                sections.push(std::mem::take(&mut current));
138                section_has_hunk = false;
139            }
140            in_hunk = false;
141            if current.git.is_none() {
142                current.git = trimmed.strip_prefix("diff --git ");
143            }
144            current.lines.push(raw);
145            continue;
146        }
147        if hunk_complete && trimmed.starts_with("--- ") {
148            // A `--- ` header opens a new file only when the current
149            // section already describes one (its own header or hunks);
150            // otherwise it is the current file's header following a
151            // `diff --git` line.
152            if !current.lines.is_empty() && (current.old.is_some() || section_has_hunk) {
153                sections.push(std::mem::take(&mut current));
154                section_has_hunk = false;
155            }
156            in_hunk = false;
157            if current.old.is_none() {
158                current.old = trimmed.strip_prefix("--- ");
159            }
160            current.lines.push(raw);
161            continue;
162        }
163        if hunk_complete && trimmed.starts_with("+++ ") && current.new.is_none() {
164            current.new = trimmed.strip_prefix("+++ ");
165        }
166        if in_hunk && !hunk_complete {
167            match trimmed.as_bytes().first().copied() {
168                Some(b'-') => seen_old += 1,
169                Some(b'+') => seen_new += 1,
170                Some(b' ') => {
171                    seen_old += 1;
172                    seen_new += 1;
173                }
174                _ => {}
175            }
176        }
177        current.lines.push(raw);
178    }
179    if !current.lines.is_empty() {
180        sections.push(current);
181    }
182
183    let mut files = Vec::with_capacity(sections.len());
184    for section in &sections {
185        let text: String = section.lines.concat();
186        let document = DiffDocument::from_unified(&text)?;
187        if document.stats.omitted_rows > 0 {
188            return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
189        }
190        if document.hunks.is_empty() {
191            return Err(ParseDiffError::new("file section without hunks"));
192        }
193        let (old_path, new_path) = section_paths(section)?;
194        files.push(UnifiedFile { old_path, new_path, hunks: document.hunks });
195    }
196    Ok(files)
197}
198
199#[derive(Default)]
200struct Section<'a> {
201    lines: Vec<&'a str>,
202    old: Option<&'a str>,
203    new: Option<&'a str>,
204    git: Option<&'a str>,
205}
206
207/// Reverses structured hunks against current content, returning the pre-image.
208///
209/// Each hunk's new-side block must occur exactly once in `current`, and the
210/// matches must run in hunk order without overlap. Edits outside the matched
211/// spans are preserved; edits inside them refuse the whole file.
212///
213/// # Requires
214///
215/// - `hunks` comes from [`split_unified_files`] (strict fidelity).
216///
217/// # Ensures
218///
219/// - `Ok(pre)` replaces exactly the recorded new-side blocks with their
220///   old-side counterparts; `Err` computes nothing partial (pure).
221/// - Total (terminating): bounded scans over `current` and `hunks`.
222pub fn reverse_apply_hunks(current: &str, hunks: &[DiffHunk]) -> Result<String, ReverseApplyError> {
223    if hunks.is_empty() {
224        return Err(ReverseApplyError::EmptyDiff);
225    }
226    if current.len() > MAX_REVERSE_FILE_BYTES {
227        return Err(ReverseApplyError::TooLarge);
228    }
229    let segments = split_line_slices(current);
230    // Loop invariant: `spans` holds one verified `(start, end, old_block)`
231    // per processed hunk, in hunk order.
232    let mut spans: Vec<(usize, usize, Vec<&str>)> = Vec::with_capacity(hunks.len());
233    for (index, hunk) in hunks.iter().enumerate() {
234        let new_block: Vec<&str> = hunk
235            .lines
236            .iter()
237            .filter(|line| line.kind != DiffLineKind::Deletion)
238            .map(|line| line.text.as_str())
239            .collect();
240        if new_block.is_empty() {
241            return Err(ReverseApplyError::EmptyNewSide { hunk: index });
242        }
243        // Two matches are enough to tell unique from ambiguous: never
244        // scan past the second.
245        let matches: Vec<usize> = segments
246            .windows(new_block.len())
247            .enumerate()
248            .filter_map(|(start, window)| (window == new_block.as_slice()).then_some(start))
249            .take(2)
250            .collect();
251        let [start] = matches.as_slice() else {
252            if matches.is_empty() {
253                return Err(ReverseApplyError::NoMatch { hunk: index });
254            }
255            return Err(ReverseApplyError::Ambiguous { hunk: index });
256        };
257        let old_block: Vec<&str> = hunk
258            .lines
259            .iter()
260            .filter(|line| line.kind != DiffLineKind::Addition)
261            .map(|line| line.text.as_str())
262            .collect();
263        spans.push((*start, start + new_block.len(), old_block));
264    }
265    for pair in spans.windows(2) {
266        let [(.., previous_end, _), (start, ..)] = pair else {
267            continue;
268        };
269        if start < previous_end {
270            return Err(ReverseApplyError::OverlappingSpans);
271        }
272    }
273
274    let mut restored = String::with_capacity(current.len());
275    let mut cursor = 0usize;
276    for (start, end, old_block) in &spans {
277        for segment in &segments[cursor..*start] {
278            restored.push_str(segment);
279        }
280        for line in old_block {
281            restored.push_str(line);
282        }
283        cursor = *end;
284    }
285    for segment in &segments[cursor..] {
286        restored.push_str(segment);
287    }
288    Ok(restored)
289}
290
291fn section_paths(section: &Section<'_>) -> Result<(Option<String>, Option<String>), ParseDiffError> {
292    if section.old.is_some() || section.new.is_some() {
293        let old_path = section.old.map(normalize_diff_path).transpose()?.flatten();
294        let new_path = section.new.map(normalize_diff_path).transpose()?.flatten();
295        return Ok((old_path, new_path));
296    }
297    if let Some(git) = section.git {
298        let (old, new) = split_git_header(git)?;
299        return Ok((normalize_diff_path(old)?, normalize_diff_path(new)?));
300    }
301    Ok((None, None))
302}
303
304fn split_git_header(rest: &str) -> Result<(&str, &str), ParseDiffError> {
305    let rest = rest.trim_end();
306    let (old, new) = if let Some(quoted) = rest.strip_prefix('"') {
307        let end = quoted
308            .find('"')
309            .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
310        let new_start = quoted[end + 1..]
311            .find('"')
312            .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
313        let new_quoted = &quoted[end + 1 + new_start + 1..];
314        let new_end = new_quoted
315            .find('"')
316            .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
317        (&quoted[..end], &new_quoted[..new_end])
318    } else {
319        let mut parts = rest.splitn(2, ' ');
320        let old = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
321        let new = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
322        (old, new)
323    };
324    if old.is_empty() || new.is_empty() {
325        return Err(ParseDiffError::new("malformed diff header"));
326    }
327    Ok((old, new))
328}
329
330fn normalize_diff_path(path: &str) -> Result<Option<String>, ParseDiffError> {
331    let path = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
332    if path == "/dev/null" {
333        return Ok(None);
334    }
335    if path.contains('\\') {
336        // C-style quoted escapes are not decoded: refusing beats guessing.
337        return Err(ParseDiffError::new("unsupported escaped diff path"));
338    }
339    Ok(Some(path.to_owned()))
340}