Skip to main content

diffler_core/
model.rs

1//! Diff model: what changed, organized as files -> hunks -> lines.
2
3use std::ops::Range;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct DiffModel {
9    pub files: Vec<FileDiff>,
10}
11
12impl DiffModel {
13    /// Cheap content identity of the whole model (file paths + per-file
14    /// sides hashes), so callers can skip invalidating derived state when
15    /// a refresh recomputed an identical diff.
16    pub fn fingerprint(&self) -> String {
17        let mut buf = Vec::new();
18        for file in &self.files {
19            buf.extend_from_slice(file.path.as_bytes());
20            buf.push(0);
21            buf.extend_from_slice(file.sides_hash().as_bytes());
22            buf.push(b'\n');
23        }
24        git2::Oid::hash_object(git2::ObjectType::Blob, &buf)
25            .map(|o| o.to_string())
26            .unwrap_or_default()
27    }
28
29    /// The diff line carrying number `line` on the requested side of
30    /// `file`'s hunks, if it is part of the diff.
31    pub fn find_line(&self, file: &str, line: u32, on_old_side: bool) -> Option<&DiffLine> {
32        let file = self.files.iter().find(|f| f.path == file)?;
33        file.hunks.iter().flat_map(|h| &h.lines).find(|l| {
34            let no = if on_old_side { l.old_no } else { l.new_no };
35            no == Some(line)
36        })
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FileDiff {
42    pub path: String,
43    pub old_path: Option<String>,
44    pub status: FileStatus,
45    pub binary: bool,
46    /// Full contents of each side, used for whole-file syntax highlighting.
47    /// `None` for binary files and for the missing side of adds/deletes.
48    pub old_text: Option<String>,
49    pub new_text: Option<String>,
50    pub hunks: Vec<Hunk>,
51}
52
53impl FileDiff {
54    /// Content identity of the new side, used for viewed-mark invalidation.
55    pub fn content_hash(&self) -> String {
56        let bytes = self.new_text.as_deref().unwrap_or("").as_bytes();
57        git2::Oid::hash_object(git2::ObjectType::Blob, bytes)
58            .map(|o| o.to_string())
59            .unwrap_or_default()
60    }
61
62    /// `(added, deleted)` line counts across the file's hunks.
63    pub fn diffstat(&self) -> (usize, usize) {
64        let mut added = 0;
65        let mut deleted = 0;
66        for line in self.hunks.iter().flat_map(|h| &h.lines) {
67            match line.kind {
68                LineKind::Added => added += 1,
69                LineKind::Deleted => deleted += 1,
70                LineKind::Context => {}
71            }
72        }
73        (added, deleted)
74    }
75
76    /// Content identity of both sides, for caches derived from old and new
77    /// text (e.g. syntax highlighting). Viewed marks key on `content_hash`
78    /// instead: they only care about the side the reviewer reads.
79    pub fn sides_hash(&self) -> String {
80        let mut bytes = Vec::from(self.old_text.as_deref().unwrap_or("").as_bytes());
81        bytes.push(0);
82        bytes.extend_from_slice(self.new_text.as_deref().unwrap_or("").as_bytes());
83        git2::Oid::hash_object(git2::ObjectType::Blob, &bytes)
84            .map(|o| o.to_string())
85            .unwrap_or_default()
86    }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum FileStatus {
92    Added,
93    Modified,
94    Deleted,
95    Renamed,
96    Untracked,
97}
98
99impl FileStatus {
100    /// Single-character indicator used in the diff sidebar (A/M/D/R/?).
101    pub const fn glyph(self) -> char {
102        match self {
103            Self::Added => 'A',
104            Self::Modified => 'M',
105            Self::Deleted => 'D',
106            Self::Renamed => 'R',
107            Self::Untracked => '?',
108        }
109    }
110
111    /// Neogit-style row label shown in file headers and the diff pane.
112    pub const fn label(self) -> &'static str {
113        match self {
114            Self::Added => "new file",
115            Self::Modified => "modified",
116            Self::Deleted => "deleted",
117            Self::Renamed => "renamed",
118            Self::Untracked => "untracked",
119        }
120    }
121}
122
123/// Stable identity for a hunk: hash of its normalized content. Survives
124/// edits elsewhere in the file; changes when the hunk's lines change.
125#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
126pub struct HunkId(pub String);
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Hunk {
130    pub id: HunkId,
131    pub old_start: u32,
132    pub old_lines: u32,
133    pub new_start: u32,
134    pub new_lines: u32,
135    /// git's section heading: the enclosing function/section name git emits
136    /// after the second `@@` of the hunk header. Empty when git gives none
137    /// (e.g. a top-of-file hunk). Excluded from `id`, which keys only on lines.
138    pub context: String,
139    pub lines: Vec<DiffLine>,
140}
141
142impl Hunk {
143    pub fn header(&self) -> String {
144        format!(
145            "@@ -{},{} +{},{} @@",
146            self.old_start, self.old_lines, self.new_start, self.new_lines
147        )
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum LineKind {
154    Context,
155    Deleted,
156    Added,
157}
158
159impl LineKind {
160    /// Unified-diff origin character (' ', '-', '+').
161    pub const fn origin(self) -> char {
162        match self {
163            Self::Context => ' ',
164            Self::Deleted => '-',
165            Self::Added => '+',
166        }
167    }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct DiffLine {
172    pub kind: LineKind,
173    pub old_no: Option<u32>,
174    pub new_no: Option<u32>,
175    /// Line content without the trailing newline.
176    pub text: String,
177    /// Byte ranges within `text` to emphasize (intra-line changes).
178    pub emphasis: Vec<Range<usize>>,
179}
180
181impl DiffLine {
182    pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
183        Self {
184            kind,
185            old_no,
186            new_no,
187            text,
188            emphasis: Vec::new(),
189        }
190    }
191}
192
193/// Hash the hunk's content (kinds + text) into a stable id using git's
194/// blob hashing, so no extra hash dependency is needed.
195pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> Result<HunkId, git2::Error> {
196    let mut buf = String::new();
197    buf.push_str(file_path);
198    buf.push('\n');
199    for line in lines {
200        // the tags mirror LineKind::origin but stay hard-coded: hunk ids are
201        // persisted in sessions, so changing the mapping would orphan every
202        // existing comment anchor
203        let tag = match line.kind {
204            LineKind::Context => ' ',
205            LineKind::Deleted => '-',
206            LineKind::Added => '+',
207        };
208        buf.push(tag);
209        buf.push_str(&line.text);
210        buf.push('\n');
211    }
212    let oid = git2::Oid::hash_object(git2::ObjectType::Blob, buf.as_bytes())?;
213    Ok(HunkId(oid.to_string()))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn file_status_glyph_and_label_cover_all_variants() {
222        assert_eq!(FileStatus::Added.glyph(), 'A');
223        assert_eq!(FileStatus::Modified.glyph(), 'M');
224        assert_eq!(FileStatus::Deleted.glyph(), 'D');
225        assert_eq!(FileStatus::Renamed.glyph(), 'R');
226        assert_eq!(FileStatus::Untracked.glyph(), '?');
227
228        assert_eq!(FileStatus::Added.label(), "new file");
229        assert_eq!(FileStatus::Modified.label(), "modified");
230        assert_eq!(FileStatus::Deleted.label(), "deleted");
231        assert_eq!(FileStatus::Renamed.label(), "renamed");
232        assert_eq!(FileStatus::Untracked.label(), "untracked");
233    }
234
235    fn line(kind: LineKind, text: &str) -> DiffLine {
236        DiffLine::new(kind, None, None, text.to_owned())
237    }
238
239    #[test]
240    fn hunk_id_is_stable() {
241        let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
242        let id1 = hunk_id("src/x.rs", &lines).expect("hash");
243        let id2 = hunk_id("src/x.rs", &lines).expect("hash");
244        assert_eq!(id1, id2);
245    }
246
247    #[test]
248    fn hunk_id_changes_with_content() {
249        let a = vec![line(LineKind::Added, "x")];
250        let b = vec![line(LineKind::Added, "y")];
251        assert_ne!(
252            hunk_id("f", &a).expect("hash"),
253            hunk_id("f", &b).expect("hash")
254        );
255    }
256
257    #[test]
258    fn hunk_id_changes_with_kind() {
259        let a = vec![line(LineKind::Added, "x")];
260        let b = vec![line(LineKind::Deleted, "x")];
261        assert_ne!(
262            hunk_id("f", &a).expect("hash"),
263            hunk_id("f", &b).expect("hash")
264        );
265    }
266
267    #[test]
268    fn hunk_id_changes_with_file() {
269        let lines = vec![line(LineKind::Added, "x")];
270        assert_ne!(
271            hunk_id("a", &lines).expect("hash"),
272            hunk_id("b", &lines).expect("hash")
273        );
274    }
275
276    #[test]
277    fn header_formats() {
278        let hunk = Hunk {
279            id: HunkId("h".into()),
280            old_start: 10,
281            old_lines: 7,
282            new_start: 10,
283            new_lines: 9,
284            context: String::new(),
285            lines: vec![],
286        };
287        assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
288    }
289
290    #[test]
291    fn content_hash_changes_when_new_text_changes() {
292        let base = FileDiff {
293            path: "f.rs".into(),
294            old_path: None,
295            status: FileStatus::Modified,
296            binary: false,
297            old_text: None,
298            new_text: Some("fn main() {}".into()),
299            hunks: vec![],
300        };
301        let mut changed = base.clone();
302        changed.new_text = Some("fn main() { let x = 1; }".into());
303        assert_ne!(base.content_hash(), changed.content_hash());
304    }
305
306    #[test]
307    fn content_hash_is_stable() {
308        let file = FileDiff {
309            path: "f.rs".into(),
310            old_path: None,
311            status: FileStatus::Modified,
312            binary: false,
313            old_text: None,
314            new_text: Some("same content".into()),
315            hunks: vec![],
316        };
317        assert_eq!(file.content_hash(), file.content_hash());
318    }
319
320    #[test]
321    fn sides_hash_changes_when_old_text_changes() {
322        let base = FileDiff {
323            path: "f.rs".into(),
324            old_path: None,
325            status: FileStatus::Modified,
326            binary: false,
327            old_text: Some("fn main() {}".into()),
328            new_text: Some("fn main() { let x = 1; }".into()),
329            hunks: vec![],
330        };
331        let mut changed = base.clone();
332        changed.old_text = Some("fn main() { unreachable!() }".into());
333        assert_eq!(
334            base.content_hash(),
335            changed.content_hash(),
336            "same new side, same content hash"
337        );
338        assert_ne!(base.sides_hash(), changed.sides_hash());
339    }
340
341    fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
342        DiffModel {
343            files: vec![FileDiff {
344                path: path.to_owned(),
345                old_path: None,
346                status: FileStatus::Modified,
347                binary: false,
348                old_text: Some(old_text.to_owned()),
349                new_text: Some(new_text.to_owned()),
350                hunks: vec![],
351            }],
352        }
353    }
354
355    #[test]
356    fn fingerprint_is_stable_for_identical_models() {
357        let a = one_file_model("f.rs", "old", "new");
358        let b = one_file_model("f.rs", "old", "new");
359        assert_eq!(a.fingerprint(), b.fingerprint());
360    }
361
362    #[test]
363    fn fingerprint_changes_with_content_path_and_file_set() {
364        let base = one_file_model("f.rs", "old", "new");
365        assert_ne!(
366            base.fingerprint(),
367            one_file_model("f.rs", "old", "newer").fingerprint(),
368            "changed side changes the fingerprint"
369        );
370        assert_ne!(
371            base.fingerprint(),
372            one_file_model("g.rs", "old", "new").fingerprint(),
373            "renamed file changes the fingerprint"
374        );
375        let mut grown = base.clone();
376        grown.files.extend(one_file_model("g.rs", "", "x").files);
377        assert_ne!(
378            base.fingerprint(),
379            grown.fingerprint(),
380            "added file changes the fingerprint"
381        );
382    }
383
384    fn model_with_lines() -> DiffModel {
385        DiffModel {
386            files: vec![FileDiff {
387                path: "f.rs".into(),
388                old_path: None,
389                status: FileStatus::Modified,
390                binary: false,
391                old_text: None,
392                new_text: None,
393                hunks: vec![Hunk {
394                    id: HunkId("h".into()),
395                    old_start: 1,
396                    old_lines: 2,
397                    new_start: 1,
398                    new_lines: 2,
399                    context: String::new(),
400                    lines: vec![
401                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
402                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
403                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
404                    ],
405                }],
406            }],
407        }
408    }
409
410    #[test]
411    fn diffstat_counts_added_and_deleted_over_hunks() {
412        // model_with_lines: one context, one deleted, one added line
413        let model = model_with_lines();
414        assert_eq!(model.files[0].diffstat(), (1, 1));
415
416        // two hunks: 2 added + 1 deleted total, context ignored
417        let file = FileDiff {
418            path: "f.rs".into(),
419            old_path: None,
420            status: FileStatus::Modified,
421            binary: false,
422            old_text: None,
423            new_text: None,
424            hunks: vec![
425                Hunk {
426                    id: HunkId("a".into()),
427                    old_start: 1,
428                    old_lines: 1,
429                    new_start: 1,
430                    new_lines: 2,
431                    context: String::new(),
432                    lines: vec![
433                        DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
434                        DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
435                    ],
436                },
437                Hunk {
438                    id: HunkId("b".into()),
439                    old_start: 5,
440                    old_lines: 1,
441                    new_start: 6,
442                    new_lines: 1,
443                    context: String::new(),
444                    lines: vec![
445                        DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
446                        DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
447                    ],
448                },
449            ],
450        };
451        assert_eq!(file.diffstat(), (2, 1));
452    }
453
454    #[test]
455    fn find_line_matches_the_requested_side() {
456        let model = model_with_lines();
457        let new_side = model.find_line("f.rs", 2, false).expect("new side");
458        assert_eq!(new_side.text, "TWO");
459        let old_side = model.find_line("f.rs", 2, true).expect("old side");
460        assert_eq!(old_side.text, "two");
461    }
462
463    #[test]
464    fn find_line_misses_unknown_files_and_lines() {
465        let model = model_with_lines();
466        assert!(model.find_line("nope.rs", 1, false).is_none());
467        assert!(model.find_line("f.rs", 99, false).is_none());
468    }
469
470    #[test]
471    fn content_hash_falls_back_for_none() {
472        let file = FileDiff {
473            path: "f.rs".into(),
474            old_path: None,
475            status: FileStatus::Deleted,
476            binary: false,
477            old_text: None,
478            new_text: None,
479            hunks: vec![],
480        };
481        // must not panic, must return a non-empty string (git hash of empty blob)
482        let hash = file.content_hash();
483        assert!(!hash.is_empty());
484    }
485}