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