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