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 DiffDisplayKind {
37    /// Whether this kind carries diff body content (context, addition, or
38    /// deletion) rather than metadata or a hunk header.
39    pub fn is_diff(self) -> bool {
40        matches!(self, Self::Context | Self::Addition | Self::Deletion)
41    }
42}
43
44impl DiffDisplayLine {
45    /// Whether this line carries diff content, without re-parsing its text.
46    pub fn is_diff(&self) -> bool {
47        self.kind.is_diff()
48    }
49
50    /// Render with an unambiguous gutter: `marker + number + │ + content`.
51    ///
52    /// The `│` separator keeps markdown bullets (`- foo`) and list markers
53    /// visually distinct from the diff marker (`+`/`-`/` `).
54    pub fn numbered_text(&self, line_number_width: usize) -> String {
55        match self.kind {
56            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
57            DiffDisplayKind::Addition => {
58                format!("+{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
59            }
60            DiffDisplayKind::Deletion => {
61                format!("-{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
62            }
63            DiffDisplayKind::Context => {
64                format!(" {:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
65            }
66        }
67    }
68}
69
70impl DiffChangeCounts {
71    pub fn total(self) -> usize {
72        self.additions + self.deletions
73    }
74}
75
76pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
77    let mut counts = DiffChangeCounts::default();
78
79    for hunk in hunks {
80        for line in &hunk.lines {
81            match line.kind {
82                DiffLineKind::Addition => counts.additions += 1,
83                DiffLineKind::Deletion => counts.deletions += 1,
84                DiffLineKind::Context => {}
85            }
86        }
87    }
88
89    counts
90}
91
92pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
93    // Each hunk contributes 1 header + its lines; pre-size to avoid reallocations
94    // on large diffs (the count is exact, so no over-allocation).
95    let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
96    let mut lines = Vec::with_capacity(total);
97
98    for hunk in hunks {
99        lines.push(DiffDisplayLine {
100            kind: DiffDisplayKind::HunkHeader,
101            line_number: None,
102            text: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
103        });
104
105        for line in &hunk.lines {
106            lines.push(display_line_from_diff_line(line));
107        }
108    }
109
110    lines
111}
112
113pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
114    // Upper-bound the capacity to the line count — the double scan is cheap
115    // (L1-bound byte search) and avoids 10+ reallocations on large diffs.
116    let mut lines = Vec::with_capacity(diff_content.lines().count());
117    let mut old_line_no = 0u32;
118    let mut new_line_no = 0u32;
119    let mut in_hunk = false;
120
121    for line in diff_content.lines() {
122        if let Some((old_start, new_start)) = parse_hunk_starts(line) {
123            old_line_no = old_start as u32;
124            new_line_no = new_start as u32;
125            in_hunk = true;
126            lines.push(DiffDisplayLine {
127                kind: DiffDisplayKind::HunkHeader,
128                line_number: None,
129                text: format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
130            });
131            continue;
132        }
133
134        if !in_hunk {
135            lines.push(DiffDisplayLine {
136                kind: DiffDisplayKind::Metadata,
137                line_number: None,
138                text: line.to_string(),
139            });
140            continue;
141        }
142
143        if is_diff_addition_line(line) {
144            lines.push(DiffDisplayLine {
145                kind: DiffDisplayKind::Addition,
146                line_number: Some(new_line_no),
147                text: line[1..].to_string(),
148            });
149            new_line_no = new_line_no.saturating_add(1);
150            continue;
151        }
152
153        if is_diff_deletion_line(line) {
154            lines.push(DiffDisplayLine {
155                kind: DiffDisplayKind::Deletion,
156                line_number: Some(old_line_no),
157                text: line[1..].to_string(),
158            });
159            old_line_no = old_line_no.saturating_add(1);
160            continue;
161        }
162
163        if let Some(context_line) = line.strip_prefix(' ') {
164            lines.push(DiffDisplayLine {
165                kind: DiffDisplayKind::Context,
166                line_number: Some(new_line_no),
167                text: context_line.to_string(),
168            });
169            old_line_no = old_line_no.saturating_add(1);
170            new_line_no = new_line_no.saturating_add(1);
171            continue;
172        }
173
174        if let Some(omitted) = parse_omitted_line_count(line) {
175            old_line_no = old_line_no.saturating_add(omitted);
176            new_line_no = new_line_no.saturating_add(omitted);
177            lines.push(DiffDisplayLine {
178                kind: DiffDisplayKind::Metadata,
179                line_number: None,
180                text: line.to_string(),
181            });
182            continue;
183        }
184
185        lines.push(DiffDisplayLine {
186            kind: DiffDisplayKind::Metadata,
187            line_number: None,
188            text: line.to_string(),
189        });
190    }
191
192    lines
193}
194
195pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
196    let max_digits = lines
197        .iter()
198        .filter_map(|line| line.line_number)
199        .map(digit_count)
200        .max()
201        .unwrap_or(4);
202    max_digits.clamp(5, 6)
203}
204
205fn digit_count(mut value: u32) -> usize {
206    let mut digits = 1;
207    while value >= 10 {
208        value /= 10;
209        digits += 1;
210    }
211    digits
212}
213
214pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
215    let display_lines = display_lines_from_unified_diff(diff_content);
216    let width = diff_display_line_number_width(&display_lines);
217    display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
218}
219
220/// Parse the number of omitted lines from a condensation marker such as
221/// `"... 12 lines omitted ..."`.
222fn parse_omitted_line_count(line: &str) -> Option<u32> {
223    let trimmed = line.trim();
224    let after = trimmed.strip_prefix("...")?;
225    let after = after.trim_start();
226    let digits_end = after.find(|ch: char| !ch.is_ascii_digit())?;
227    if digits_end == 0 {
228        return None;
229    }
230    after[..digits_end].parse().ok()
231}
232
233fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
234    let text = line.text.trim_end_matches('\n').to_string();
235    match line.kind {
236        DiffLineKind::Context => DiffDisplayLine {
237            kind: DiffDisplayKind::Context,
238            line_number: line.new_line,
239            text,
240        },
241        DiffLineKind::Addition => DiffDisplayLine {
242            kind: DiffDisplayKind::Addition,
243            line_number: line.new_line,
244            text,
245        },
246        DiffLineKind::Deletion => DiffDisplayLine {
247            kind: DiffDisplayKind::Deletion,
248            line_number: line.old_line,
249            text,
250        },
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::diff::{DiffLine, DiffLineKind};
258
259    #[test]
260    fn counts_diff_changes_from_hunks() {
261        let hunks = vec![DiffHunk {
262            old_start: 1,
263            old_lines: 2,
264            new_start: 1,
265            new_lines: 2,
266            lines: vec![
267                DiffLine {
268                    kind: DiffLineKind::Context,
269                    old_line: Some(1),
270                    new_line: Some(1),
271                    text: "same\n".to_string(),
272                },
273                DiffLine {
274                    kind: DiffLineKind::Deletion,
275                    old_line: Some(2),
276                    new_line: None,
277                    text: "old\n".to_string(),
278                },
279                DiffLine {
280                    kind: DiffLineKind::Addition,
281                    old_line: None,
282                    new_line: Some(2),
283                    text: "new\n".to_string(),
284                },
285            ],
286        }];
287
288        let counts = count_diff_changes(&hunks);
289        assert_eq!(counts.additions, 1);
290        assert_eq!(counts.deletions, 1);
291        assert_eq!(counts.total(), 2);
292    }
293
294    #[test]
295    fn formats_numbered_unified_diff_with_start_only_headers() {
296        let diff = "\
297diff --git a/file.txt b/file.txt
298@@ -10,2 +10,2 @@
299-old
300+new
301 context
302";
303
304        let lines = format_numbered_unified_diff(diff);
305        assert_eq!(lines[0], "diff --git a/file.txt b/file.txt");
306        assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
307        assert!(lines.iter().any(|line| line.starts_with("-   10 │ old")));
308        assert!(lines.iter().any(|line| line.starts_with("+   10 │ new")));
309        assert!(lines.iter().any(|line| line.starts_with("    11 │ context")));
310    }
311
312    #[test]
313    fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
314        let line = DiffDisplayLine {
315            kind: DiffDisplayKind::Addition,
316            line_number: Some(53),
317            text: "- **Agent-first by design**: prose".to_string(),
318        };
319        assert_eq!(line.numbered_text(5), "+   53 │ - **Agent-first by design**: prose");
320    }
321
322    #[test]
323    fn display_lines_from_hunks_preserves_semantics() {
324        let hunks = vec![DiffHunk {
325            old_start: 10,
326            old_lines: 2,
327            new_start: 10,
328            new_lines: 2,
329            lines: vec![
330                DiffLine {
331                    kind: DiffLineKind::Deletion,
332                    old_line: Some(10),
333                    new_line: None,
334                    text: "old\n".to_string(),
335                },
336                DiffLine {
337                    kind: DiffLineKind::Addition,
338                    old_line: None,
339                    new_line: Some(10),
340                    text: "new\n".to_string(),
341                },
342                DiffLine {
343                    kind: DiffLineKind::Context,
344                    old_line: Some(11),
345                    new_line: Some(11),
346                    text: "same\n".to_string(),
347                },
348            ],
349        }];
350
351        let lines = display_lines_from_hunks(&hunks);
352        assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
353        assert_eq!(lines[0].text, "@@ -10 +10 @@");
354        assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
355        assert_eq!(lines[1].line_number, Some(10));
356        assert_eq!(lines[1].text, "old");
357        assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
358        assert_eq!(lines[2].line_number, Some(10));
359        assert_eq!(lines[3].kind, DiffDisplayKind::Context);
360        assert_eq!(lines[3].line_number, Some(11));
361    }
362
363    #[test]
364    fn diff_display_line_number_width_tracks_max_digits() {
365        let lines = vec![
366            DiffDisplayLine {
367                kind: DiffDisplayKind::Addition,
368                line_number: Some(99),
369                text: "let a = 1;".to_string(),
370            },
371            DiffDisplayLine {
372                kind: DiffDisplayKind::Context,
373                line_number: Some(10_420),
374                text: "let b = 2;".to_string(),
375            },
376        ];
377
378        assert_eq!(diff_display_line_number_width(&lines), 5);
379    }
380
381    #[test]
382    fn preserves_plain_text_when_not_diff() {
383        let lines = format_numbered_unified_diff("plain text output");
384        assert_eq!(lines, vec!["plain text output".to_string()]);
385    }
386
387    #[test]
388    fn is_diff_discriminates_content_lines() {
389        let diff = "\
390diff --git a/file.txt b/file.txt
391@@ -1 +1 @@
392-old
393+new
394 context
395";
396        let lines = display_lines_from_unified_diff(diff);
397        assert_eq!(lines[0].kind, DiffDisplayKind::Metadata);
398        assert!(!lines[0].is_diff());
399        assert_eq!(lines[1].kind, DiffDisplayKind::HunkHeader);
400        assert!(!lines[1].is_diff());
401        assert!(lines[2].is_diff());
402        assert!(lines[3].is_diff());
403        assert!(lines[4].is_diff());
404    }
405
406    #[test]
407    fn omitted_marker_advances_both_counters() {
408        let diff = "\
409@@ -1,5 +1,5 @@
410-one
411... 3 lines omitted ...
412 old tail
413";
414
415        let lines = display_lines_from_unified_diff(diff);
416        assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
417        assert_eq!(lines[1].line_number, Some(1));
418        assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
419        assert_eq!(lines[2].line_number, None);
420        assert_eq!(lines[3].kind, DiffDisplayKind::Context);
421        assert_eq!(lines[3].line_number, Some(4));
422    }
423
424    #[test]
425    fn diff_display_line_number_width_clamps_to_bounds() {
426        let small = vec![DiffDisplayLine {
427            kind: DiffDisplayKind::Context,
428            line_number: Some(1),
429            text: "text".to_string(),
430        }];
431        assert_eq!(diff_display_line_number_width(&small), 5);
432
433        let large = vec![DiffDisplayLine {
434            kind: DiffDisplayKind::Context,
435            line_number: Some(100_000),
436            text: "text".to_string(),
437        }];
438        assert_eq!(diff_display_line_number_width(&large), 6);
439    }
440
441    #[test]
442    fn metadata_lines_stay_metadata_after_hunk() {
443        let diff = "\
444@@ -1 +1 @@
445-old
446\\ No newline at end of file
447+new
448";
449
450        let lines = display_lines_from_unified_diff(diff);
451        assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
452        assert_eq!(lines[2].line_number, None);
453        assert_eq!(lines[3].kind, DiffDisplayKind::Addition);
454        assert_eq!(lines[3].line_number, Some(1));
455    }
456}