Skip to main content

vtcode_commons/
diff_preview.rs

1#![expect(
2    clippy::string_slice,
3    clippy::cast_possible_truncation,
4    reason = "Preview offsets are derived from bounded diff lines and converted to the documented display width."
5)]
6
7//! Shared helpers for rendering diff previews.
8
9use crate::diff::{DiffHunk, DiffLineKind};
10use crate::diff_paths::{
11    format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line, parse_hunk_starts,
12};
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub struct DiffChangeCounts {
16    pub additions: usize,
17    pub deletions: usize,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum DiffDisplayKind {
22    Metadata,
23    HunkHeader,
24    Context,
25    Addition,
26    Deletion,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct DiffDisplayLine {
31    pub kind: DiffDisplayKind,
32    pub line_number: Option<u32>,
33    pub text: String,
34}
35
36impl DiffDisplayLine {
37    /// Render with an unambiguous gutter: `marker + number + │ + content`.
38    ///
39    /// The `│` separator keeps markdown bullets (`- foo`) and list markers
40    /// visually distinct from the diff marker (`+`/`-`/` `).
41    pub fn numbered_text(&self, line_number_width: usize) -> String {
42        match self.kind {
43            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
44            DiffDisplayKind::Addition => {
45                format!("+{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
46            }
47            DiffDisplayKind::Deletion => {
48                format!("-{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
49            }
50            DiffDisplayKind::Context => {
51                format!(" {:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
52            }
53        }
54    }
55}
56
57impl DiffChangeCounts {
58    pub fn total(self) -> usize {
59        self.additions + self.deletions
60    }
61}
62
63pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
64    let mut counts = DiffChangeCounts::default();
65
66    for hunk in hunks {
67        for line in &hunk.lines {
68            match line.kind {
69                DiffLineKind::Addition => counts.additions += 1,
70                DiffLineKind::Deletion => counts.deletions += 1,
71                DiffLineKind::Context => {}
72            }
73        }
74    }
75
76    counts
77}
78
79pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
80    // Each hunk contributes 1 header + its lines; pre-size to avoid reallocations
81    // on large diffs (the count is exact, so no over-allocation).
82    let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
83    let mut lines = Vec::with_capacity(total);
84
85    for hunk in hunks {
86        lines.push(DiffDisplayLine {
87            kind: DiffDisplayKind::HunkHeader,
88            line_number: None,
89            text: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
90        });
91
92        for line in &hunk.lines {
93            lines.push(display_line_from_diff_line(line));
94        }
95    }
96
97    lines
98}
99
100pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
101    // Upper-bound the capacity to the line count — the double scan is cheap
102    // (L1-bound byte search) and avoids 10+ reallocations on large diffs.
103    let mut lines = Vec::with_capacity(diff_content.lines().count());
104    let mut old_line_no = 0u32;
105    let mut new_line_no = 0u32;
106    let mut in_hunk = false;
107
108    for line in diff_content.lines() {
109        if let Some((old_start, new_start)) = parse_hunk_starts(line) {
110            old_line_no = old_start as u32;
111            new_line_no = new_start as u32;
112            in_hunk = true;
113            lines.push(DiffDisplayLine {
114                kind: DiffDisplayKind::HunkHeader,
115                line_number: None,
116                text: format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
117            });
118            continue;
119        }
120
121        if !in_hunk {
122            lines.push(DiffDisplayLine {
123                kind: DiffDisplayKind::Metadata,
124                line_number: None,
125                text: line.to_string(),
126            });
127            continue;
128        }
129
130        if is_diff_addition_line(line) {
131            lines.push(DiffDisplayLine {
132                kind: DiffDisplayKind::Addition,
133                line_number: Some(new_line_no),
134                text: line[1..].to_string(),
135            });
136            new_line_no = new_line_no.saturating_add(1);
137            continue;
138        }
139
140        if is_diff_deletion_line(line) {
141            lines.push(DiffDisplayLine {
142                kind: DiffDisplayKind::Deletion,
143                line_number: Some(old_line_no),
144                text: line[1..].to_string(),
145            });
146            old_line_no = old_line_no.saturating_add(1);
147            continue;
148        }
149
150        if let Some(context_line) = line.strip_prefix(' ') {
151            lines.push(DiffDisplayLine {
152                kind: DiffDisplayKind::Context,
153                line_number: Some(new_line_no),
154                text: context_line.to_string(),
155            });
156            old_line_no = old_line_no.saturating_add(1);
157            new_line_no = new_line_no.saturating_add(1);
158            continue;
159        }
160
161        lines.push(DiffDisplayLine {
162            kind: DiffDisplayKind::Metadata,
163            line_number: None,
164            text: line.to_string(),
165        });
166    }
167
168    lines
169}
170
171pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
172    let max_digits = lines
173        .iter()
174        .filter_map(|line| line.line_number.map(|line_no| line_no.to_string().len()))
175        .max()
176        .unwrap_or(4);
177    max_digits.clamp(5, 6)
178}
179
180pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
181    let display_lines = display_lines_from_unified_diff(diff_content);
182    let width = diff_display_line_number_width(&display_lines);
183    display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
184}
185
186fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
187    let text = line.text.trim_end_matches('\n').to_string();
188    match line.kind {
189        DiffLineKind::Context => DiffDisplayLine {
190            kind: DiffDisplayKind::Context,
191            line_number: line.new_line,
192            text,
193        },
194        DiffLineKind::Addition => DiffDisplayLine {
195            kind: DiffDisplayKind::Addition,
196            line_number: line.new_line,
197            text,
198        },
199        DiffLineKind::Deletion => DiffDisplayLine {
200            kind: DiffDisplayKind::Deletion,
201            line_number: line.old_line,
202            text,
203        },
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::diff::{DiffLine, DiffLineKind};
211
212    #[test]
213    fn counts_diff_changes_from_hunks() {
214        let hunks = vec![DiffHunk {
215            old_start: 1,
216            old_lines: 2,
217            new_start: 1,
218            new_lines: 2,
219            lines: vec![
220                DiffLine {
221                    kind: DiffLineKind::Context,
222                    old_line: Some(1),
223                    new_line: Some(1),
224                    text: "same\n".to_string(),
225                },
226                DiffLine {
227                    kind: DiffLineKind::Deletion,
228                    old_line: Some(2),
229                    new_line: None,
230                    text: "old\n".to_string(),
231                },
232                DiffLine {
233                    kind: DiffLineKind::Addition,
234                    old_line: None,
235                    new_line: Some(2),
236                    text: "new\n".to_string(),
237                },
238            ],
239        }];
240
241        let counts = count_diff_changes(&hunks);
242        assert_eq!(counts.additions, 1);
243        assert_eq!(counts.deletions, 1);
244        assert_eq!(counts.total(), 2);
245    }
246
247    #[test]
248    fn formats_numbered_unified_diff_with_start_only_headers() {
249        let diff = "\
250diff --git a/file.txt b/file.txt
251@@ -10,2 +10,2 @@
252-old
253+new
254 context
255";
256
257        let lines = format_numbered_unified_diff(diff);
258        assert_eq!(lines[0], "diff --git a/file.txt b/file.txt");
259        assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
260        assert!(lines.iter().any(|line| line.starts_with("-   10 │ old")));
261        assert!(lines.iter().any(|line| line.starts_with("+   10 │ new")));
262        assert!(lines.iter().any(|line| line.starts_with("    11 │ context")));
263    }
264
265    #[test]
266    fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
267        let line = DiffDisplayLine {
268            kind: DiffDisplayKind::Addition,
269            line_number: Some(53),
270            text: "- **Agent-first by design**: prose".to_string(),
271        };
272        assert_eq!(line.numbered_text(5), "+   53 │ - **Agent-first by design**: prose");
273    }
274
275    #[test]
276    fn display_lines_from_hunks_preserves_semantics() {
277        let hunks = vec![DiffHunk {
278            old_start: 10,
279            old_lines: 2,
280            new_start: 10,
281            new_lines: 2,
282            lines: vec![
283                DiffLine {
284                    kind: DiffLineKind::Deletion,
285                    old_line: Some(10),
286                    new_line: None,
287                    text: "old\n".to_string(),
288                },
289                DiffLine {
290                    kind: DiffLineKind::Addition,
291                    old_line: None,
292                    new_line: Some(10),
293                    text: "new\n".to_string(),
294                },
295                DiffLine {
296                    kind: DiffLineKind::Context,
297                    old_line: Some(11),
298                    new_line: Some(11),
299                    text: "same\n".to_string(),
300                },
301            ],
302        }];
303
304        let lines = display_lines_from_hunks(&hunks);
305        assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
306        assert_eq!(lines[0].text, "@@ -10 +10 @@");
307        assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
308        assert_eq!(lines[1].line_number, Some(10));
309        assert_eq!(lines[1].text, "old");
310        assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
311        assert_eq!(lines[2].line_number, Some(10));
312        assert_eq!(lines[3].kind, DiffDisplayKind::Context);
313        assert_eq!(lines[3].line_number, Some(11));
314    }
315
316    #[test]
317    fn diff_display_line_number_width_tracks_max_digits() {
318        let lines = vec![
319            DiffDisplayLine {
320                kind: DiffDisplayKind::Addition,
321                line_number: Some(99),
322                text: "let a = 1;".to_string(),
323            },
324            DiffDisplayLine {
325                kind: DiffDisplayKind::Context,
326                line_number: Some(10_420),
327                text: "let b = 2;".to_string(),
328            },
329        ];
330
331        assert_eq!(diff_display_line_number_width(&lines), 5);
332    }
333
334    #[test]
335    fn preserves_plain_text_when_not_diff() {
336        let lines = format_numbered_unified_diff("plain text output");
337        assert_eq!(lines, vec!["plain text output".to_string()]);
338    }
339}