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 => 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 = 0u32;
96    let mut new_line_no = 0u32;
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 as u32;
102            new_line_no = new_start as u32;
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(5, 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.into_iter().map(|line| line.numbered_text(width)).collect()
176}
177
178fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
179    let text = line.text.trim_end_matches('\n').to_string();
180    match line.kind {
181        DiffLineKind::Context => DiffDisplayLine {
182            kind: DiffDisplayKind::Context,
183            line_number: line.new_line,
184            text,
185        },
186        DiffLineKind::Addition => DiffDisplayLine {
187            kind: DiffDisplayKind::Addition,
188            line_number: line.new_line,
189            text,
190        },
191        DiffLineKind::Deletion => DiffDisplayLine {
192            kind: DiffDisplayKind::Deletion,
193            line_number: line.old_line,
194            text,
195        },
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::diff::{DiffLine, DiffLineKind};
203
204    #[test]
205    fn counts_diff_changes_from_hunks() {
206        let hunks = vec![DiffHunk {
207            old_start: 1,
208            old_lines: 2,
209            new_start: 1,
210            new_lines: 2,
211            lines: vec![
212                DiffLine {
213                    kind: DiffLineKind::Context,
214                    old_line: Some(1),
215                    new_line: Some(1),
216                    text: "same\n".to_string(),
217                },
218                DiffLine {
219                    kind: DiffLineKind::Deletion,
220                    old_line: Some(2),
221                    new_line: None,
222                    text: "old\n".to_string(),
223                },
224                DiffLine {
225                    kind: DiffLineKind::Addition,
226                    old_line: None,
227                    new_line: Some(2),
228                    text: "new\n".to_string(),
229                },
230            ],
231        }];
232
233        let counts = count_diff_changes(&hunks);
234        assert_eq!(counts.additions, 1);
235        assert_eq!(counts.deletions, 1);
236        assert_eq!(counts.total(), 2);
237    }
238
239    #[test]
240    fn formats_numbered_unified_diff_with_start_only_headers() {
241        let diff = "\
242diff --git a/file.txt b/file.txt
243@@ -10,2 +10,2 @@
244-old
245+new
246 context
247";
248
249        let lines = format_numbered_unified_diff(diff);
250        assert_eq!(lines[0], "diff --git a/file.txt b/file.txt");
251        assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
252        assert!(lines.iter().any(|line| line.starts_with("-   10 old")));
253        assert!(lines.iter().any(|line| line.starts_with("+   10 new")));
254        assert!(lines.iter().any(|line| line.starts_with("    11 context")));
255    }
256
257    #[test]
258    fn display_lines_from_hunks_preserves_semantics() {
259        let hunks = vec![DiffHunk {
260            old_start: 10,
261            old_lines: 2,
262            new_start: 10,
263            new_lines: 2,
264            lines: vec![
265                DiffLine {
266                    kind: DiffLineKind::Deletion,
267                    old_line: Some(10),
268                    new_line: None,
269                    text: "old\n".to_string(),
270                },
271                DiffLine {
272                    kind: DiffLineKind::Addition,
273                    old_line: None,
274                    new_line: Some(10),
275                    text: "new\n".to_string(),
276                },
277                DiffLine {
278                    kind: DiffLineKind::Context,
279                    old_line: Some(11),
280                    new_line: Some(11),
281                    text: "same\n".to_string(),
282                },
283            ],
284        }];
285
286        let lines = display_lines_from_hunks(&hunks);
287        assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
288        assert_eq!(lines[0].text, "@@ -10 +10 @@");
289        assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
290        assert_eq!(lines[1].line_number, Some(10));
291        assert_eq!(lines[1].text, "old");
292        assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
293        assert_eq!(lines[2].line_number, Some(10));
294        assert_eq!(lines[3].kind, DiffDisplayKind::Context);
295        assert_eq!(lines[3].line_number, Some(11));
296    }
297
298    #[test]
299    fn diff_display_line_number_width_tracks_max_digits() {
300        let lines = vec![
301            DiffDisplayLine {
302                kind: DiffDisplayKind::Addition,
303                line_number: Some(99),
304                text: "let a = 1;".to_string(),
305            },
306            DiffDisplayLine {
307                kind: DiffDisplayKind::Context,
308                line_number: Some(10_420),
309                text: "let b = 2;".to_string(),
310            },
311        ];
312
313        assert_eq!(diff_display_line_number_width(&lines), 5);
314    }
315
316    #[test]
317    fn preserves_plain_text_when_not_diff() {
318        let lines = format_numbered_unified_diff("plain text output");
319        assert_eq!(lines, vec!["plain text output".to_string()]);
320    }
321}