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