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///
80/// # Contract (Creusot-style)
81///
82/// - Requires: `old_start` and `new_start` are one-based starts (`0` only for
83///   empty-side hunks such as `@@ -0,0 +1 @@`).
84/// - Ensures (hunk invariant): `lines` contains exactly `old_lines`
85///   old-side entries (Context + Deletion) and exactly `new_lines` new-side
86///   entries (Context + Addition). See [`DiffHunk::counts_match`].
87#[derive(Debug, Clone, PartialEq, Eq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct DiffHunk {
90    /// One-based old-side start line.
91    pub old_start: usize,
92    /// Number of represented old-side lines.
93    pub old_lines: usize,
94    /// One-based new-side start line.
95    pub new_start: usize,
96    /// Number of represented new-side lines.
97    pub new_lines: usize,
98    /// Lines contained in the hunk.
99    pub lines: Vec<DiffLine>,
100}
101
102impl DiffHunk {
103    /// Pearlite-style logical predicate: do the embedded lines satisfy the
104    /// declared old/new counts?
105    ///
106    /// # Ensures
107    ///
108    /// Returns `true` iff old-side lines equal `old_count` and new-side lines
109    /// equal `new_count`. Total (terminating): iterates `lines` once.
110    #[must_use]
111    pub fn counts_match(&self, old_count: usize, new_count: usize) -> bool {
112        let old_side = self.lines.iter().filter(|line| line.kind != DiffLineKind::Addition).count();
113        let new_side = self.lines.iter().filter(|line| line.kind != DiffLineKind::Deletion).count();
114        old_side == old_count && new_side == new_count
115    }
116}
117
118/// The semantic role of a line inside a hunk.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
122pub enum DiffLineKind {
123    /// Unchanged context.
124    Context,
125    /// New-side insertion.
126    Addition,
127    /// Old-side deletion.
128    Deletion,
129}
130
131/// A source line and its old/new positions.
132#[derive(Debug, Clone, PartialEq, Eq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct DiffLine {
135    /// Semantic line role.
136    pub kind: DiffLineKind,
137    /// One-based old-side line number, when present.
138    pub old_line: Option<u32>,
139    /// One-based new-side line number, when present.
140    pub new_line: Option<u32>,
141    /// Source text, including its original line terminator when present.
142    pub text: String,
143}
144
145/// Aggregate statistics for a complete document.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
148pub struct DiffStats {
149    /// Added lines.
150    pub additions: usize,
151    /// Deleted lines.
152    pub deletions: usize,
153    /// Context lines represented by hunks.
154    pub context: usize,
155    /// Number of hunks.
156    pub hunks: usize,
157    /// Semantic rows omitted by a bounded layout.
158    pub omitted_rows: usize,
159}
160
161/// A complete renderer-independent diff.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164pub struct DiffDocument {
165    /// Structured hunks.
166    pub hunks: Vec<DiffHunk>,
167    /// Whole-document statistics.
168    pub stats: DiffStats,
169    #[cfg_attr(feature = "serde", serde(default = "default_inline_timeout"))]
170    pub(crate) inline_timeout: Duration,
171}
172
173impl Default for DiffDocument {
174    fn default() -> Self {
175        Self {
176            hunks: Vec::new(),
177            stats: DiffStats::default(),
178            inline_timeout: default_inline_timeout(),
179        }
180    }
181}
182
183pub(crate) fn default_inline_timeout() -> Duration {
184    Duration::from_millis(40)
185}
186
187/// Error returned for malformed unified diff input.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct ParseDiffError {
190    message: &'static str,
191}
192
193impl ParseDiffError {
194    pub(crate) fn new(message: &'static str) -> Self {
195        Self { message }
196    }
197}
198
199impl fmt::Display for ParseDiffError {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter.write_str(self.message)
202    }
203}
204
205impl std::error::Error for ParseDiffError {}
206
207/// A diff rendered with both structured hunks and formatted text.
208#[derive(Debug, Clone)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub struct DiffBundle {
211    /// Structured hunks.
212    pub hunks: Vec<DiffHunk>,
213    /// Caller-formatted representation.
214    pub formatted: String,
215    /// Whether both inputs were identical.
216    pub is_empty: bool,
217}
218/// Intraline byte range, always aligned to UTF-8 boundaries.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
221pub struct IntralineRange {
222    /// Inclusive byte start.
223    pub start: usize,
224    /// Exclusive byte end.
225    pub end: usize,
226}
227
228/// Intra-line highlight ranges retained for compatibility.
229pub type WordChangedRanges = Vec<(usize, usize)>;
230
231/// Aggregate addition/deletion counts retained for compatibility.
232#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
233pub struct DiffChangeCounts {
234    /// Added lines.
235    pub additions: usize,
236    /// Deleted lines.
237    pub deletions: usize,
238}
239
240impl DiffChangeCounts {
241    /// Total changed lines.
242    ///
243    /// # Ensures
244    ///
245    /// Returns `additions.saturating_add(deletions)`: overflow-free
246    /// (Creusot absence-of-overflow obligation). Total and pure.
247    #[must_use]
248    pub const fn total(self) -> usize {
249        self.additions.saturating_add(self.deletions)
250    }
251}
252
253/// Semantic role used by legacy preview consumers.
254#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
255pub enum DiffDisplayKind {
256    /// File or parser metadata.
257    Metadata,
258    /// Hunk range header.
259    HunkHeader,
260    /// Unchanged context.
261    Context,
262    /// Added content.
263    Addition,
264    /// Deleted content.
265    Deletion,
266}
267
268impl DiffDisplayKind {
269    /// Whether the kind represents source content.
270    #[must_use]
271    pub const fn is_diff(self) -> bool {
272        matches!(self, Self::Context | Self::Addition | Self::Deletion)
273    }
274}
275
276/// A semantic legacy display line.
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct DiffDisplayLine {
279    /// Semantic role.
280    pub kind: DiffDisplayKind,
281    /// Old-side number.
282    pub old_line: Option<u32>,
283    /// New-side number.
284    pub new_line: Option<u32>,
285    /// Content without the diff marker or line ending.
286    pub text: String,
287    /// Intraline changed byte ranges.
288    pub changed: WordChangedRanges,
289}
290
291impl DiffDisplayLine {
292    /// Constructs a source body line.
293    #[must_use]
294    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
295        Self {
296            kind,
297            old_line,
298            new_line,
299            text,
300            changed: Vec::new(),
301        }
302    }
303
304    /// Whether this line represents source content.
305    #[must_use]
306    pub const fn is_diff(&self) -> bool {
307        self.kind.is_diff()
308    }
309
310    /// Formats a single numbered gutter.
311    #[must_use]
312    pub fn numbered_text(&self, line_number_width: usize) -> String {
313        match self.kind {
314            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
315            DiffDisplayKind::Deletion => {
316                format!("-{:>width$} │ {}", self.old_line.unwrap_or_default(), self.text, width = line_number_width)
317            }
318            DiffDisplayKind::Addition => {
319                format!("+{:>width$} │ {}", self.new_line.unwrap_or_default(), self.text, width = line_number_width)
320            }
321            DiffDisplayKind::Context => format!(
322                " {:>width$} │ {}",
323                self.new_line.or(self.old_line).unwrap_or_default(),
324                self.text,
325                width = line_number_width
326            ),
327        }
328    }
329}
330/// Minimum content width for the paired old/new preview.
331///
332/// Below this width, two independently numbered panes leave too little room
333/// for source text. Renderers should fall back to the unified presentation.
334pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
335
336/// Whether a measured width can support the paired old/new preview.
337///
338/// An unknown width preserves the existing caller behavior; redirected
339/// output and test sinks may not expose terminal sizing at all.
340#[must_use]
341pub const fn diff_side_by_side_fits(available_width: Option<usize>) -> bool {
342    match available_width {
343        Some(width) => width >= DIFF_MIN_SIDE_BY_SIDE_WIDTH,
344        None => true,
345    }
346}
347
348/// Minimum source width retained when the unified line gutter is visible.
349///
350/// This keeps the marker, line number, separator, and a useful amount of
351/// source text together. Narrower layouts should hide the gutter and give its
352/// columns back to the source body.
353pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
354
355/// Width consumed by a unified diff gutter after the line-number field.
356///
357/// The rendered shape is `+123 │ `: one marker plus the three-cell separator
358/// around `│`. The line-number field is supplied by the caller because it is
359/// derived from the visible diff excerpt.
360#[must_use]
361pub const fn diff_gutter_width(line_number_width: usize) -> usize {
362    line_number_width.saturating_add(4)
363}
364
365/// Whether a unified diff can keep its marker, line number, and separator
366/// without starving the source body.
367#[must_use]
368pub const fn diff_gutter_fits(available_width: usize, line_number_width: usize) -> bool {
369    available_width >= diff_gutter_width(line_number_width).saturating_add(DIFF_MIN_BODY_WIDTH_WITH_GUTTER)
370}
371
372/// Width to pass to semantic unified layout when the renderer hides its
373/// visible gutter.
374///
375/// `layout_display_lines` subtracts the normal gutter before wrapping source
376/// text. Giving that width back keeps wrapping aligned with compact rendering
377/// without adding another public layout option.
378#[must_use]
379pub const fn diff_layout_width(available_width: usize, line_number_width: usize, show_gutter: bool) -> usize {
380    if show_gutter {
381        available_width
382    } else {
383        available_width.saturating_add(diff_gutter_width(line_number_width))
384    }
385}
386/// One paired row in the compatibility side-by-side model.
387#[derive(Clone, Debug, Eq, PartialEq)]
388pub struct SideBySideRow {
389    /// Old-side cell.
390    pub left: Option<DiffDisplayLine>,
391    /// New-side cell.
392    pub right: Option<DiffDisplayLine>,
393}
394
395impl SideBySideRow {
396    /// Whether the left cell is a header spanning both panes.
397    #[must_use]
398    pub fn is_full_width(&self) -> bool {
399        self.right.is_none()
400            && self
401                .left
402                .as_ref()
403                .is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
404    }
405}
406/// Requested preview layout.
407#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
408#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
409#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
410pub enum DiffLayout {
411    /// One stacked old/new column.
412    #[default]
413    Unified,
414    /// Paired old/new panes.
415    SideBySide,
416}
417
418/// Width and bounded-excerpt options for semantic layout.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct LayoutOptions {
421    /// Requested layout.
422    pub layout: DiffLayout,
423    /// Available terminal columns.
424    pub width: usize,
425    /// Maximum rows, including an omission marker.
426    pub max_rows: usize,
427    /// Whether source bodies hard-wrap at display width.
428    pub wrap: bool,
429    /// Side-by-side fallback threshold.
430    pub min_side_by_side_width: usize,
431}
432
433impl Default for LayoutOptions {
434    fn default() -> Self {
435        Self {
436            layout: DiffLayout::Unified,
437            width: 80,
438            max_rows: 2_000,
439            wrap: true,
440            min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
441        }
442    }
443}
444
445/// Semantic role for a renderer-neutral row.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum DiffRowKind {
448    /// File metadata outside a hunk.
449    Metadata,
450    /// Hunk header.
451    HunkHeader,
452    /// Unchanged content.
453    Context,
454    /// Added content.
455    Addition,
456    /// Deleted content.
457    Deletion,
458    /// A bounded middle omission.
459    Omission,
460}
461
462/// One independently styled content segment.
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct DiffSegment {
465    /// Segment text.
466    pub text: String,
467    /// Whether the segment receives intraline emphasis.
468    pub emphasized: bool,
469}
470
471/// One side of a semantic preview row.
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct DiffCell {
474    /// Old-side line number.
475    pub old_line: Option<u32>,
476    /// New-side line number.
477    pub new_line: Option<u32>,
478    /// Diff marker.
479    pub marker: char,
480    /// Styled content segments.
481    pub segments: Vec<DiffSegment>,
482}
483
484/// A renderer-neutral row, optionally containing paired side-by-side cells.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct DiffRow {
487    /// Semantic role.
488    pub kind: DiffRowKind,
489    /// Marker for unified renderers and simple inspection.
490    pub marker: char,
491    /// Stable zero-based hunk identity.
492    pub hunk_index: Option<usize>,
493    /// Whether this row continues a hard-wrapped source line.
494    pub continuation: bool,
495    /// Unified or old-side cell.
496    pub left: Option<DiffCell>,
497    /// New-side cell in side-by-side mode.
498    pub right: Option<DiffCell>,
499}