Skip to main content

strop_git/
diff.rs

1//! Diff data: hunks, line origins, signs — the typed model every
2//! git surface renders from (0010).
3
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub enum HunkKind {
8    Add,
9    Change,
10    Delete,
11}
12
13/// Where a diff line comes from — addition/deletion carry which side's
14/// line number applies (0010 §1: typed origins, never `+`-sniffing).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16pub enum LineOrigin {
17    Context,
18    Addition,
19    Deletion,
20}
21
22/// One line of a hunk: content without prefix, plus the 1-based line
23/// number on each side that has one (absent side: `None`, never `0`).
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub struct DiffLine {
26    pub origin: LineOrigin,
27    pub old_lineno: Option<usize>,
28    pub new_lineno: Option<usize>,
29    /// The line's bytes WITHOUT its terminator (0018: byte-precise —
30    /// a `\r` stays; non-UTF-8 stays bytes).
31    pub text: Vec<u8>,
32    /// Whether the source line ended in a newline — the missing-final-
33    /// newline marker is data, not a patch-text nuance (0018).
34    pub has_newline: bool,
35}
36
37impl DiffLine {
38    /// Display form (lossy at the render edge only).
39    pub fn text_str(&self) -> std::borrow::Cow<'_, str> {
40        String::from_utf8_lossy(&self.text)
41    }
42    /// The line's bytes WITH its terminator, exactly as stored.
43    pub fn bytes_with_terminator(&self) -> Vec<u8> {
44        let mut b = self.text.clone();
45        if self.has_newline {
46            b.push(b'\n');
47        }
48        b
49    }
50}
51
52/// One diff hunk between two versions of a file, in 1-based lines.
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54pub struct Hunk {
55    pub kind: HunkKind,
56    /// First affected line in the new version (1-based). For pure
57    /// deletions this is the line *after* which content vanished.
58    pub new_start: usize,
59    pub new_count: usize,
60    pub old_start: usize,
61    pub old_count: usize,
62    pub lines: Vec<DiffLine>,
63}
64
65/// One file's diff at a commit (vs its parent): the delta view's data.
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct FileDiff {
68    #[serde(with = "strop_core::path_serde")]
69    pub path: PathBuf,
70    pub hunks: Vec<Hunk>,
71    pub added: usize,
72    pub deleted: usize,
73}
74
75impl FileDiff {
76    /// Assemble the delta from typed hunks, deriving the added/deleted
77    /// counts from line origins — the one constructor both the local
78    /// libgit2 path and the remote blob-diff path feed.
79    pub(crate) fn from_hunks(path: PathBuf, hunks: Vec<Hunk>) -> Self {
80        let added = hunks
81            .iter()
82            .flat_map(|h| &h.lines)
83            .filter(|l| l.origin == LineOrigin::Addition)
84            .count();
85        let deleted = hunks
86            .iter()
87            .flat_map(|h| &h.lines)
88            .filter(|l| l.origin == LineOrigin::Deletion)
89            .count();
90        Self {
91            path,
92            hunks,
93            added,
94            deleted,
95        }
96    }
97}
98
99/// One changed line, for gutter signs. Hunk headers include context
100/// lines, so signs track the +/- lines, not the header range.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Sign {
103    /// Buffer line was added or changed.
104    AddOrChange,
105    /// Buffer line sits right below a deletion (the line number may be
106    /// one past the buffer end for an EOF deletion — clamp on render).
107    DeleteAfter,
108}
109
110impl Hunk {
111    /// Signs this hunk produces, derived from its line origins.
112    pub fn signs(&self) -> Vec<(usize, Sign)> {
113        let mut out = Vec::new();
114        let mut nl = self.new_start;
115        for line in &self.lines {
116            match line.origin {
117                LineOrigin::Addition => {
118                    out.push((nl, Sign::AddOrChange));
119                    nl += 1;
120                }
121                LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
122                LineOrigin::Context => nl += 1,
123            }
124        }
125        out
126    }
127
128    /// The actual changed region (from add/del lines, not the header,
129    /// which includes context): new-side `new_first`/`new_count`
130    /// (1-based) and old-side `old_first`/`old_count`. For pure
131    /// deletions `new_first` is the new line *following* the gap.
132    pub fn changed_region(&self) -> (usize, usize, usize, usize) {
133        let mut nl = self.new_start;
134        let mut ol = self.old_start;
135        let mut new_lines = Vec::new();
136        let mut old_lines = Vec::new();
137        for line in &self.lines {
138            match line.origin {
139                LineOrigin::Addition => {
140                    new_lines.push(nl);
141                    nl += 1;
142                }
143                LineOrigin::Deletion => {
144                    old_lines.push(ol);
145                    ol += 1;
146                }
147                LineOrigin::Context => {
148                    nl += 1;
149                    ol += 1;
150                }
151            }
152        }
153        let new_first = new_lines.first().copied().unwrap_or(nl);
154        let old_first = old_lines.first().copied().unwrap_or(ol);
155        (new_first, new_lines.len(), old_first, old_lines.len())
156    }
157
158    /// Buffer lines covered (signs render on these); `total_lines`
159    /// clamps an EOF deletion onto the last line.
160    pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
161        self.signs().iter().any(|&(l, kind)| match kind {
162            Sign::AddOrChange => l == line_1based,
163            Sign::DeleteAfter => l.min(total_lines) == line_1based,
164        })
165    }
166
167    /// The `@@ -a,b +c,d @@` header row as the diff surface shows it.
168    pub fn header(&self) -> String {
169        format!(
170            "@@ -{},{} +{},{} @@",
171            self.old_start, self.old_count, self.new_start, self.new_count
172        )
173    }
174
175    /// Assemble a hunk from its header numbers and typed lines; the
176    /// kind comes from the actual origins — header counts include
177    /// context lines, which would mislabel small-file hunks.
178    pub fn build(
179        old_start: usize,
180        old_count: usize,
181        new_start: usize,
182        new_count: usize,
183        lines: Vec<DiffLine>,
184    ) -> Self {
185        let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
186        let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
187        let kind = match (has_add, has_del) {
188            (true, false) => HunkKind::Add,
189            (false, true) => HunkKind::Delete,
190            _ => HunkKind::Change,
191        };
192        Hunk {
193            kind,
194            new_start,
195            new_count,
196            old_start,
197            old_count,
198            lines,
199        }
200    }
201}