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