Skip to main content

vtcode_commons/
diff_preview.rs

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