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