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