Skip to main content

diffler_core/
model.rs

1//! Diff model: files, hunks, lines.
2
3use std::ops::Range;
4
5use serde::{Deserialize, Serialize};
6
7/// FNV-1a 64 as lowercase hex. Persisted viewed marks key on it, so the
8/// algorithm must never change.
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    /// Content identity of the whole model, so a refresh that recomputed an
25    /// identical diff keeps derived state.
26    pub fn fingerprint(&self) -> String {
27        let mut buf = Vec::new();
28        for file in &self.files {
29            buf.extend_from_slice(file.path.as_bytes());
30            buf.push(0);
31            buf.extend_from_slice(file.sides_hash().as_bytes());
32            buf.push(b'\n');
33        }
34        stable_hash(&buf)
35    }
36
37    pub fn find_line(&self, file: &str, line: u32, on_old_side: bool) -> Option<&DiffLine> {
38        let file = self.files.iter().find(|f| f.path == file)?;
39        file.hunks.iter().flat_map(|h| &h.lines).find(|l| {
40            let no = if on_old_side { l.old_no } else { l.new_no };
41            no == Some(line)
42        })
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct FileDiff {
48    pub path: String,
49    pub old_path: Option<String>,
50    pub status: FileStatus,
51    pub binary: bool,
52    /// Full contents of each side. `None` for binary files and for the
53    /// missing side of adds/deletes.
54    pub old_text: Option<String>,
55    pub new_text: Option<String>,
56    pub hunks: Vec<Hunk>,
57    pub hashes: HashCache,
58    pub blobs: BlobIds,
59}
60
61/// Hex blob ids of a file's two sides. A working-tree side's id has no
62/// object behind it in the store.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct BlobIds {
65    pub old: Option<String>,
66    pub new: Option<String>,
67}
68
69/// Memo for [`FileDiff::content_hash`] and [`FileDiff::sides_hash`], which
70/// the UI reads every frame. Always compares equal so `FileDiff` equality is
71/// on data.
72#[derive(Debug, Clone, Default)]
73pub struct HashCache {
74    content: std::sync::OnceLock<String>,
75    sides: std::sync::OnceLock<String>,
76}
77
78impl PartialEq for HashCache {
79    fn eq(&self, _: &Self) -> bool {
80        true
81    }
82}
83
84impl Eq for HashCache {}
85
86impl FileDiff {
87    /// Identity of the new side, which viewed marks key on. A binary file
88    /// uses its new blob id.
89    pub fn content_hash(&self) -> String {
90        self.hashes
91            .content
92            .get_or_init(|| match (&self.new_text, &self.blobs.new) {
93                (None, Some(blob)) => blob.clone(),
94                (text, _) => stable_hash(text.as_deref().unwrap_or("").as_bytes()),
95            })
96            .clone()
97    }
98
99    /// `(added, deleted)` line counts.
100    pub fn diffstat(&self) -> (usize, usize) {
101        let mut added = 0;
102        let mut deleted = 0;
103        for line in self.hunks.iter().flat_map(|h| &h.lines) {
104            match line.kind {
105                LineKind::Added => added += 1,
106                LineKind::Deleted => deleted += 1,
107                LineKind::Context => {}
108            }
109        }
110        (added, deleted)
111    }
112
113    /// Identity of both sides, for caches derived from old and new text.
114    pub fn sides_hash(&self) -> String {
115        self.hashes
116            .sides
117            .get_or_init(|| {
118                let mut bytes = Vec::from(self.old_text.as_deref().unwrap_or("").as_bytes());
119                bytes.push(0);
120                bytes.extend_from_slice(self.new_text.as_deref().unwrap_or("").as_bytes());
121                for blob in [&self.blobs.old, &self.blobs.new] {
122                    bytes.push(0);
123                    bytes.extend_from_slice(blob.as_deref().unwrap_or("").as_bytes());
124                }
125                stable_hash(&bytes)
126            })
127            .clone()
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum FileStatus {
134    Added,
135    Modified,
136    Deleted,
137    Renamed,
138    Untracked,
139    /// A file a walkthrough anchors outside the diff.
140    Unchanged,
141}
142
143impl FileStatus {
144    /// Each status has a distinct shape so it reads without colour.
145    pub const fn glyph(self) -> char {
146        match self {
147            Self::Added => '+',
148            Self::Modified => '●',
149            Self::Deleted => '−',
150            Self::Renamed => '~',
151            Self::Untracked => '○',
152            Self::Unchanged => '·',
153        }
154    }
155
156    pub const fn label(self) -> &'static str {
157        match self {
158            Self::Added => "new file",
159            Self::Modified => "modified",
160            Self::Deleted => "deleted",
161            Self::Renamed => "renamed",
162            Self::Untracked => "untracked",
163            Self::Unchanged => "unchanged",
164        }
165    }
166}
167
168/// Hash of a hunk's content, so it survives edits elsewhere in the file.
169#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
170pub struct HunkId(pub String);
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Hunk {
174    pub id: HunkId,
175    pub old_start: u32,
176    pub old_lines: u32,
177    pub new_start: u32,
178    pub new_lines: u32,
179    /// git's function heading after the second `@@`, empty when git gives
180    /// none. `id` ignores it.
181    pub context: String,
182    pub lines: Vec<DiffLine>,
183}
184
185impl Hunk {
186    pub fn header(&self) -> String {
187        format!(
188            "@@ -{},{} +{},{} @@",
189            self.old_start, self.old_lines, self.new_start, self.new_lines
190        )
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum LineKind {
197    Context,
198    Deleted,
199    Added,
200}
201
202impl LineKind {
203    pub const fn origin(self) -> char {
204        match self {
205            Self::Context => ' ',
206            Self::Deleted => '-',
207            Self::Added => '+',
208        }
209    }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct DiffLine {
214    pub kind: LineKind,
215    pub old_no: Option<u32>,
216    pub new_no: Option<u32>,
217    /// Without the trailing newline.
218    pub text: String,
219    /// Byte ranges within `text`.
220    pub emphasis: Vec<Range<usize>>,
221    /// A paired line the structural algorithm found differs in layout alone.
222    pub reformat_only: bool,
223}
224
225impl DiffLine {
226    pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
227        Self {
228            kind,
229            old_no,
230            new_no,
231            text,
232            emphasis: Vec::new(),
233            reformat_only: false,
234        }
235    }
236
237    pub const fn number_on(&self, old_side: bool) -> Option<u32> {
238        if old_side { self.old_no } else { self.new_no }
239    }
240}
241
242/// `occurrence` counts earlier hunks in the file with the same content, so
243/// identical hunks get distinct ids. Prefer [`disambiguated_hunk_id`].
244pub fn hunk_id(file_path: &str, lines: &[DiffLine], occurrence: usize) -> HunkId {
245    let mut buf = String::new();
246    buf.push_str(file_path);
247    buf.push('\n');
248    for line in lines {
249        let tag = match line.kind {
250            LineKind::Context => ' ',
251            LineKind::Deleted => '-',
252            LineKind::Added => '+',
253        };
254        buf.push(tag);
255        buf.push_str(&line.text);
256        buf.push('\n');
257    }
258    if occurrence > 0 {
259        buf.push_str(&occurrence.to_string());
260        buf.push('\n');
261    }
262    HunkId(stable_hash(buf.as_bytes()))
263}
264
265/// [`hunk_id`] with `seen` counting occurrences across one file's hunks.
266// every caller shares one default-hashed map for one file's hunks, so a
267// generic hasher buys nothing
268#[allow(clippy::implicit_hasher)]
269pub fn disambiguated_hunk_id(
270    file_path: &str,
271    lines: &[DiffLine],
272    seen: &mut std::collections::HashMap<HunkId, usize>,
273) -> HunkId {
274    let base = hunk_id(file_path, lines, 0);
275    let occurrence = seen.entry(base).or_insert(0);
276    let id = hunk_id(file_path, lines, *occurrence);
277    *occurrence += 1;
278    id
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn stable_hash_is_fnv1a64_and_never_changes() {
287        assert_eq!(stable_hash(b""), "cbf29ce484222325");
288        assert_eq!(stable_hash(b"hello"), "a430d84680aabd0b");
289    }
290
291    #[test]
292    fn file_status_glyph_and_label_cover_all_variants() {
293        let glyphs = [
294            FileStatus::Added,
295            FileStatus::Modified,
296            FileStatus::Deleted,
297            FileStatus::Renamed,
298            FileStatus::Untracked,
299            FileStatus::Unchanged,
300        ]
301        .map(FileStatus::glyph);
302        assert_eq!(glyphs, ['+', '●', '−', '~', '○', '·']);
303        let mut distinct = glyphs.to_vec();
304        distinct.sort_unstable();
305        distinct.dedup();
306        assert_eq!(
307            distinct.len(),
308            glyphs.len(),
309            "no two statuses share a shape"
310        );
311
312        assert_eq!(FileStatus::Added.label(), "new file");
313        assert_eq!(FileStatus::Modified.label(), "modified");
314        assert_eq!(FileStatus::Deleted.label(), "deleted");
315        assert_eq!(FileStatus::Renamed.label(), "renamed");
316        assert_eq!(FileStatus::Untracked.label(), "untracked");
317        assert_eq!(FileStatus::Unchanged.label(), "unchanged");
318    }
319
320    fn line(kind: LineKind, text: &str) -> DiffLine {
321        DiffLine::new(kind, None, None, text.to_owned())
322    }
323
324    #[test]
325    fn hunk_id_is_stable() {
326        let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
327        let id1 = hunk_id("src/x.rs", &lines, 0);
328        let id2 = hunk_id("src/x.rs", &lines, 0);
329        assert_eq!(id1, id2);
330    }
331
332    #[test]
333    fn hunk_id_changes_with_content() {
334        let a = vec![line(LineKind::Added, "x")];
335        let b = vec![line(LineKind::Added, "y")];
336        assert_ne!(hunk_id("f", &a, 0), hunk_id("f", &b, 0));
337    }
338
339    #[test]
340    fn hunk_id_changes_with_kind() {
341        let a = vec![line(LineKind::Added, "x")];
342        let b = vec![line(LineKind::Deleted, "x")];
343        assert_ne!(hunk_id("f", &a, 0), hunk_id("f", &b, 0));
344    }
345
346    #[test]
347    fn hunk_id_changes_with_file() {
348        let lines = vec![line(LineKind::Added, "x")];
349        assert_ne!(hunk_id("a", &lines, 0), hunk_id("b", &lines, 0));
350    }
351
352    #[test]
353    fn hunk_id_changes_with_occurrence() {
354        let lines = vec![line(LineKind::Added, "x")];
355        assert_ne!(hunk_id("f", &lines, 0), hunk_id("f", &lines, 1));
356    }
357
358    #[test]
359    fn disambiguated_hunk_id_gives_identical_hunks_distinct_ids() {
360        let lines = vec![line(LineKind::Added, "x")];
361        let mut seen = std::collections::HashMap::new();
362        let first = disambiguated_hunk_id("f", &lines, &mut seen);
363        let second = disambiguated_hunk_id("f", &lines, &mut seen);
364        assert_ne!(first, second);
365        assert_eq!(first, hunk_id("f", &lines, 0));
366        assert_eq!(second, hunk_id("f", &lines, 1));
367    }
368
369    #[test]
370    fn header_formats() {
371        let hunk = Hunk {
372            id: HunkId("h".into()),
373            old_start: 10,
374            old_lines: 7,
375            new_start: 10,
376            new_lines: 9,
377            context: String::new(),
378            lines: vec![],
379        };
380        assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
381    }
382
383    #[test]
384    fn content_hash_changes_when_new_text_changes() {
385        let base = FileDiff {
386            path: "f.rs".into(),
387            old_path: None,
388            status: FileStatus::Modified,
389            binary: false,
390            old_text: None,
391            new_text: Some("fn main() {}".into()),
392            hunks: vec![],
393            hashes: HashCache::default(),
394            blobs: BlobIds::default(),
395        };
396        let mut changed = base.clone();
397        changed.new_text = Some("fn main() { let x = 1; }".into());
398        assert_ne!(base.content_hash(), changed.content_hash());
399    }
400
401    #[test]
402    fn content_hash_is_stable() {
403        let file = FileDiff {
404            path: "f.rs".into(),
405            old_path: None,
406            status: FileStatus::Modified,
407            binary: false,
408            old_text: None,
409            new_text: Some("same content".into()),
410            hunks: vec![],
411            hashes: HashCache::default(),
412            blobs: BlobIds::default(),
413        };
414        assert_eq!(file.content_hash(), file.content_hash());
415    }
416
417    #[test]
418    fn sides_hash_changes_when_old_text_changes() {
419        let base = FileDiff {
420            path: "f.rs".into(),
421            old_path: None,
422            status: FileStatus::Modified,
423            binary: false,
424            old_text: Some("fn main() {}".into()),
425            new_text: Some("fn main() { let x = 1; }".into()),
426            hunks: vec![],
427            hashes: HashCache::default(),
428            blobs: BlobIds::default(),
429        };
430        let mut changed = base.clone();
431        changed.old_text = Some("fn main() { unreachable!() }".into());
432        assert_eq!(
433            base.content_hash(),
434            changed.content_hash(),
435            "same new side, same content hash"
436        );
437        assert_ne!(base.sides_hash(), changed.sides_hash());
438    }
439
440    fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
441        DiffModel {
442            files: vec![FileDiff {
443                path: path.to_owned(),
444                old_path: None,
445                status: FileStatus::Modified,
446                binary: false,
447                old_text: Some(old_text.to_owned()),
448                new_text: Some(new_text.to_owned()),
449                hunks: vec![],
450                hashes: HashCache::default(),
451                blobs: BlobIds::default(),
452            }],
453        }
454    }
455
456    #[test]
457    fn fingerprint_is_stable_for_identical_models() {
458        let a = one_file_model("f.rs", "old", "new");
459        let b = one_file_model("f.rs", "old", "new");
460        assert_eq!(a.fingerprint(), b.fingerprint());
461    }
462
463    #[test]
464    fn fingerprint_changes_with_content_path_and_file_set() {
465        let base = one_file_model("f.rs", "old", "new");
466        assert_ne!(
467            base.fingerprint(),
468            one_file_model("f.rs", "old", "newer").fingerprint(),
469            "changed side changes the fingerprint"
470        );
471        assert_ne!(
472            base.fingerprint(),
473            one_file_model("g.rs", "old", "new").fingerprint(),
474            "renamed file changes the fingerprint"
475        );
476        let mut grown = base.clone();
477        grown.files.extend(one_file_model("g.rs", "", "x").files);
478        assert_ne!(
479            base.fingerprint(),
480            grown.fingerprint(),
481            "added file changes the fingerprint"
482        );
483    }
484
485    fn model_with_lines() -> DiffModel {
486        DiffModel {
487            files: vec![FileDiff {
488                path: "f.rs".into(),
489                old_path: None,
490                status: FileStatus::Modified,
491                binary: false,
492                old_text: None,
493                new_text: None,
494                hunks: vec![Hunk {
495                    id: HunkId("h".into()),
496                    old_start: 1,
497                    old_lines: 2,
498                    new_start: 1,
499                    new_lines: 2,
500                    context: String::new(),
501                    lines: vec![
502                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
503                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
504                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
505                    ],
506                }],
507                hashes: HashCache::default(),
508                blobs: BlobIds::default(),
509            }],
510        }
511    }
512
513    #[test]
514    fn diffstat_counts_added_and_deleted_over_hunks() {
515        let model = model_with_lines();
516        assert_eq!(model.files[0].diffstat(), (1, 1));
517
518        let file = FileDiff {
519            path: "f.rs".into(),
520            old_path: None,
521            status: FileStatus::Modified,
522            binary: false,
523            old_text: None,
524            new_text: None,
525            hunks: vec![
526                Hunk {
527                    id: HunkId("a".into()),
528                    old_start: 1,
529                    old_lines: 1,
530                    new_start: 1,
531                    new_lines: 2,
532                    context: String::new(),
533                    lines: vec![
534                        DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
535                        DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
536                    ],
537                },
538                Hunk {
539                    id: HunkId("b".into()),
540                    old_start: 5,
541                    old_lines: 1,
542                    new_start: 6,
543                    new_lines: 1,
544                    context: String::new(),
545                    lines: vec![
546                        DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
547                        DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
548                    ],
549                },
550            ],
551            hashes: HashCache::default(),
552            blobs: BlobIds::default(),
553        };
554        assert_eq!(file.diffstat(), (2, 1));
555    }
556
557    #[test]
558    fn find_line_matches_the_requested_side() {
559        let model = model_with_lines();
560        let new_side = model.find_line("f.rs", 2, false).expect("new side");
561        assert_eq!(new_side.text, "TWO");
562        let old_side = model.find_line("f.rs", 2, true).expect("old side");
563        assert_eq!(old_side.text, "two");
564    }
565
566    #[test]
567    fn find_line_misses_unknown_files_and_lines() {
568        let model = model_with_lines();
569        assert!(model.find_line("nope.rs", 1, false).is_none());
570        assert!(model.find_line("f.rs", 99, false).is_none());
571    }
572
573    #[test]
574    fn content_hash_falls_back_for_none() {
575        let file = FileDiff {
576            path: "f.rs".into(),
577            old_path: None,
578            status: FileStatus::Deleted,
579            binary: false,
580            old_text: None,
581            new_text: None,
582            hunks: vec![],
583            hashes: HashCache::default(),
584            blobs: BlobIds::default(),
585        };
586        let hash = file.content_hash();
587        assert!(!hash.is_empty());
588    }
589}