Skip to main content

vtcode_diff/
types.rs

1//! Renderer-independent diff data types and their inherent constructors.
2//!
3//! This module owns the public vocabulary of the crate: the structured hunk
4//! model, the semantic display-line model, layout request types, and the
5//! responsive width constants shared by renderers.
6
7use std::fmt;
8use std::time::Duration;
9
10/// A contiguous borrowed character-level diff chunk.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Chunk<'a> {
13    /// Text present on both sides.
14    Equal(&'a str),
15    /// Text present only on the old side.
16    Delete(&'a str),
17    /// Text present only on the new side.
18    Insert(&'a str),
19}
20
21/// Algorithms suitable for interactive diff previews.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
25pub enum DiffAlgorithm {
26    /// Practical, heuristic Myers diff.
27    #[default]
28    Myers,
29    /// Patience diff, useful for source code with unique anchors.
30    Patience,
31    /// Histogram diff, useful for repeated source-code lines.
32    Histogram,
33}
34
35impl DiffAlgorithm {
36    pub(crate) fn similar(self) -> similar::Algorithm {
37        match self {
38            Self::Myers => similar::Algorithm::Myers,
39            Self::Patience => similar::Algorithm::Patience,
40            Self::Histogram => similar::Algorithm::Histogram,
41        }
42    }
43}
44
45/// Options controlling diff generation.
46#[derive(Debug, Clone)]
47pub struct DiffOptions<'a> {
48    /// Number of unchanged lines retained around a change.
49    pub context_lines: usize,
50    /// Optional old-side label used by formatters.
51    pub old_label: Option<&'a str>,
52    /// Optional new-side label used by formatters.
53    pub new_label: Option<&'a str>,
54    /// Whether formatters should emit missing-final-newline markers.
55    pub missing_newline_hint: bool,
56    /// Line diff algorithm.
57    pub algorithm: DiffAlgorithm,
58    /// Maximum line-diff computation time.
59    pub timeout: Duration,
60    /// Maximum total time spent on intraline refinement.
61    pub inline_timeout: Duration,
62}
63
64impl Default for DiffOptions<'_> {
65    fn default() -> Self {
66        Self {
67            context_lines: 3,
68            old_label: None,
69            new_label: None,
70            missing_newline_hint: true,
71            algorithm: DiffAlgorithm::Myers,
72            timeout: Duration::from_millis(200),
73            inline_timeout: Duration::from_millis(40),
74        }
75    }
76}
77
78/// A diff hunk with old/new ranges and semantic lines.
79#[derive(Debug, Clone, PartialEq, Eq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81pub struct DiffHunk {
82    /// One-based old-side start line.
83    pub old_start: usize,
84    /// Number of represented old-side lines.
85    pub old_lines: usize,
86    /// One-based new-side start line.
87    pub new_start: usize,
88    /// Number of represented new-side lines.
89    pub new_lines: usize,
90    /// Lines contained in the hunk.
91    pub lines: Vec<DiffLine>,
92}
93
94/// The semantic role of a line inside a hunk.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
98pub enum DiffLineKind {
99    /// Unchanged context.
100    Context,
101    /// New-side insertion.
102    Addition,
103    /// Old-side deletion.
104    Deletion,
105}
106
107/// A source line and its old/new positions.
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct DiffLine {
111    /// Semantic line role.
112    pub kind: DiffLineKind,
113    /// One-based old-side line number, when present.
114    pub old_line: Option<u32>,
115    /// One-based new-side line number, when present.
116    pub new_line: Option<u32>,
117    /// Source text, including its original line terminator when present.
118    pub text: String,
119}
120
121/// Aggregate statistics for a complete document.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub struct DiffStats {
125    /// Added lines.
126    pub additions: usize,
127    /// Deleted lines.
128    pub deletions: usize,
129    /// Context lines represented by hunks.
130    pub context: usize,
131    /// Number of hunks.
132    pub hunks: usize,
133    /// Semantic rows omitted by a bounded layout.
134    pub omitted_rows: usize,
135}
136
137/// A complete renderer-independent diff.
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140pub struct DiffDocument {
141    /// Structured hunks.
142    pub hunks: Vec<DiffHunk>,
143    /// Whole-document statistics.
144    pub stats: DiffStats,
145    #[cfg_attr(feature = "serde", serde(default = "default_inline_timeout"))]
146    pub(crate) inline_timeout: Duration,
147}
148
149impl Default for DiffDocument {
150    fn default() -> Self {
151        Self {
152            hunks: Vec::new(),
153            stats: DiffStats::default(),
154            inline_timeout: default_inline_timeout(),
155        }
156    }
157}
158
159pub(crate) fn default_inline_timeout() -> Duration {
160    Duration::from_millis(40)
161}
162
163/// Error returned for malformed unified diff input.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ParseDiffError {
166    message: &'static str,
167}
168
169impl ParseDiffError {
170    pub(crate) fn new(message: &'static str) -> Self {
171        Self { message }
172    }
173}
174
175impl fmt::Display for ParseDiffError {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter.write_str(self.message)
178    }
179}
180
181impl std::error::Error for ParseDiffError {}
182
183/// A diff rendered with both structured hunks and formatted text.
184#[derive(Debug, Clone)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
186pub struct DiffBundle {
187    /// Structured hunks.
188    pub hunks: Vec<DiffHunk>,
189    /// Caller-formatted representation.
190    pub formatted: String,
191    /// Whether both inputs were identical.
192    pub is_empty: bool,
193}
194/// Intraline byte range, always aligned to UTF-8 boundaries.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
197pub struct IntralineRange {
198    /// Inclusive byte start.
199    pub start: usize,
200    /// Exclusive byte end.
201    pub end: usize,
202}
203
204/// Intra-line highlight ranges retained for compatibility.
205pub type WordChangedRanges = Vec<(usize, usize)>;
206
207/// Aggregate addition/deletion counts retained for compatibility.
208#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
209pub struct DiffChangeCounts {
210    /// Added lines.
211    pub additions: usize,
212    /// Deleted lines.
213    pub deletions: usize,
214}
215
216impl DiffChangeCounts {
217    /// Total changed lines.
218    #[must_use]
219    pub const fn total(self) -> usize {
220        self.additions + self.deletions
221    }
222}
223
224/// Semantic role used by legacy preview consumers.
225#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
226pub enum DiffDisplayKind {
227    /// File or parser metadata.
228    Metadata,
229    /// Hunk range header.
230    HunkHeader,
231    /// Unchanged context.
232    Context,
233    /// Added content.
234    Addition,
235    /// Deleted content.
236    Deletion,
237}
238
239impl DiffDisplayKind {
240    /// Whether the kind represents source content.
241    #[must_use]
242    pub const fn is_diff(self) -> bool {
243        matches!(self, Self::Context | Self::Addition | Self::Deletion)
244    }
245}
246
247/// A semantic legacy display line.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct DiffDisplayLine {
250    /// Semantic role.
251    pub kind: DiffDisplayKind,
252    /// Old-side number.
253    pub old_line: Option<u32>,
254    /// New-side number.
255    pub new_line: Option<u32>,
256    /// Content without the diff marker or line ending.
257    pub text: String,
258    /// Intraline changed byte ranges.
259    pub changed: WordChangedRanges,
260}
261
262impl DiffDisplayLine {
263    /// Constructs a source body line.
264    #[must_use]
265    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
266        Self {
267            kind,
268            old_line,
269            new_line,
270            text,
271            changed: Vec::new(),
272        }
273    }
274
275    /// Whether this line represents source content.
276    #[must_use]
277    pub const fn is_diff(&self) -> bool {
278        self.kind.is_diff()
279    }
280
281    /// Formats a single numbered gutter.
282    #[must_use]
283    pub fn numbered_text(&self, line_number_width: usize) -> String {
284        match self.kind {
285            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
286            DiffDisplayKind::Deletion => {
287                format!("-{:>width$} │ {}", self.old_line.unwrap_or_default(), self.text, width = line_number_width)
288            }
289            DiffDisplayKind::Addition => {
290                format!("+{:>width$} │ {}", self.new_line.unwrap_or_default(), self.text, width = line_number_width)
291            }
292            DiffDisplayKind::Context => format!(
293                " {:>width$} │ {}",
294                self.new_line.or(self.old_line).unwrap_or_default(),
295                self.text,
296                width = line_number_width
297            ),
298        }
299    }
300}
301/// Minimum content width for the paired old/new preview.
302///
303/// Below this width, two independently numbered panes leave too little room
304/// for source text. Renderers should fall back to the unified presentation.
305pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
306
307/// Whether a measured width can support the paired old/new preview.
308///
309/// An unknown width preserves the existing caller behavior; redirected
310/// output and test sinks may not expose terminal sizing at all.
311#[must_use]
312pub const fn diff_side_by_side_fits(available_width: Option<usize>) -> bool {
313    match available_width {
314        Some(width) => width >= DIFF_MIN_SIDE_BY_SIDE_WIDTH,
315        None => true,
316    }
317}
318
319/// Minimum source width retained when the unified line gutter is visible.
320///
321/// This keeps the marker, line number, separator, and a useful amount of
322/// source text together. Narrower layouts should hide the gutter and give its
323/// columns back to the source body.
324pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
325
326/// Width consumed by a unified diff gutter after the line-number field.
327///
328/// The rendered shape is `+123 │ `: one marker plus the three-cell separator
329/// around `│`. The line-number field is supplied by the caller because it is
330/// derived from the visible diff excerpt.
331#[must_use]
332pub const fn diff_gutter_width(line_number_width: usize) -> usize {
333    line_number_width.saturating_add(4)
334}
335
336/// Whether a unified diff can keep its marker, line number, and separator
337/// without starving the source body.
338#[must_use]
339pub const fn diff_gutter_fits(available_width: usize, line_number_width: usize) -> bool {
340    available_width >= diff_gutter_width(line_number_width).saturating_add(DIFF_MIN_BODY_WIDTH_WITH_GUTTER)
341}
342
343/// Width to pass to semantic unified layout when the renderer hides its
344/// visible gutter.
345///
346/// `layout_display_lines` subtracts the normal gutter before wrapping source
347/// text. Giving that width back keeps wrapping aligned with compact rendering
348/// without adding another public layout option.
349#[must_use]
350pub const fn diff_layout_width(available_width: usize, line_number_width: usize, show_gutter: bool) -> usize {
351    if show_gutter {
352        available_width
353    } else {
354        available_width.saturating_add(diff_gutter_width(line_number_width))
355    }
356}
357/// One paired row in the compatibility side-by-side model.
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct SideBySideRow {
360    /// Old-side cell.
361    pub left: Option<DiffDisplayLine>,
362    /// New-side cell.
363    pub right: Option<DiffDisplayLine>,
364}
365
366impl SideBySideRow {
367    /// Whether the left cell is a header spanning both panes.
368    #[must_use]
369    pub fn is_full_width(&self) -> bool {
370        self.right.is_none()
371            && self
372                .left
373                .as_ref()
374                .is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
375    }
376}
377/// Requested preview layout.
378#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
380#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
381pub enum DiffLayout {
382    /// One stacked old/new column.
383    #[default]
384    Unified,
385    /// Paired old/new panes.
386    SideBySide,
387}
388
389/// Width and bounded-excerpt options for semantic layout.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub struct LayoutOptions {
392    /// Requested layout.
393    pub layout: DiffLayout,
394    /// Available terminal columns.
395    pub width: usize,
396    /// Maximum rows, including an omission marker.
397    pub max_rows: usize,
398    /// Whether source bodies hard-wrap at display width.
399    pub wrap: bool,
400    /// Side-by-side fallback threshold.
401    pub min_side_by_side_width: usize,
402}
403
404impl Default for LayoutOptions {
405    fn default() -> Self {
406        Self {
407            layout: DiffLayout::Unified,
408            width: 80,
409            max_rows: 2_000,
410            wrap: true,
411            min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
412        }
413    }
414}
415
416/// Semantic role for a renderer-neutral row.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum DiffRowKind {
419    /// File metadata outside a hunk.
420    Metadata,
421    /// Hunk header.
422    HunkHeader,
423    /// Unchanged content.
424    Context,
425    /// Added content.
426    Addition,
427    /// Deleted content.
428    Deletion,
429    /// A bounded middle omission.
430    Omission,
431}
432
433/// One independently styled content segment.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct DiffSegment {
436    /// Segment text.
437    pub text: String,
438    /// Whether the segment receives intraline emphasis.
439    pub emphasized: bool,
440}
441
442/// One side of a semantic preview row.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct DiffCell {
445    /// Old-side line number.
446    pub old_line: Option<u32>,
447    /// New-side line number.
448    pub new_line: Option<u32>,
449    /// Diff marker.
450    pub marker: char,
451    /// Styled content segments.
452    pub segments: Vec<DiffSegment>,
453}
454
455/// A renderer-neutral row, optionally containing paired side-by-side cells.
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct DiffRow {
458    /// Semantic role.
459    pub kind: DiffRowKind,
460    /// Marker for unified renderers and simple inspection.
461    pub marker: char,
462    /// Stable zero-based hunk identity.
463    pub hunk_index: Option<usize>,
464    /// Whether this row continues a hard-wrapped source line.
465    pub continuation: bool,
466    /// Unified or old-side cell.
467    pub left: Option<DiffCell>,
468    /// New-side cell in side-by-side mode.
469    pub right: Option<DiffCell>,
470}