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    /// An added/deleted line the semantic engine found unchanged (only
180    /// reindented or moved). The UI marks it with a thin rail instead of a
181    /// full +/- background. Always false for the textual engine.
182    pub moved: bool,
183}
184
185impl DiffLine {
186    pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
187        Self {
188            kind,
189            old_no,
190            new_no,
191            text,
192            emphasis: Vec::new(),
193            moved: false,
194        }
195    }
196}
197
198/// Hash the hunk's content (kinds + text) into a stable id using git's
199/// blob hashing, so no extra hash dependency is needed.
200pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> Result<HunkId, git2::Error> {
201    let mut buf = String::new();
202    buf.push_str(file_path);
203    buf.push('\n');
204    for line in lines {
205        // the tags mirror LineKind::origin but stay hard-coded: hunk ids are
206        // persisted in sessions, so changing the mapping would orphan every
207        // existing comment anchor
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    let oid = git2::Oid::hash_object(git2::ObjectType::Blob, buf.as_bytes())?;
218    Ok(HunkId(oid.to_string()))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn file_status_glyph_and_label_cover_all_variants() {
227        assert_eq!(FileStatus::Added.glyph(), 'A');
228        assert_eq!(FileStatus::Modified.glyph(), 'M');
229        assert_eq!(FileStatus::Deleted.glyph(), 'D');
230        assert_eq!(FileStatus::Renamed.glyph(), 'R');
231        assert_eq!(FileStatus::Untracked.glyph(), '?');
232
233        assert_eq!(FileStatus::Added.label(), "new file");
234        assert_eq!(FileStatus::Modified.label(), "modified");
235        assert_eq!(FileStatus::Deleted.label(), "deleted");
236        assert_eq!(FileStatus::Renamed.label(), "renamed");
237        assert_eq!(FileStatus::Untracked.label(), "untracked");
238    }
239
240    fn line(kind: LineKind, text: &str) -> DiffLine {
241        DiffLine::new(kind, None, None, text.to_owned())
242    }
243
244    #[test]
245    fn hunk_id_is_stable() {
246        let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
247        let id1 = hunk_id("src/x.rs", &lines).expect("hash");
248        let id2 = hunk_id("src/x.rs", &lines).expect("hash");
249        assert_eq!(id1, id2);
250    }
251
252    #[test]
253    fn hunk_id_changes_with_content() {
254        let a = vec![line(LineKind::Added, "x")];
255        let b = vec![line(LineKind::Added, "y")];
256        assert_ne!(
257            hunk_id("f", &a).expect("hash"),
258            hunk_id("f", &b).expect("hash")
259        );
260    }
261
262    #[test]
263    fn hunk_id_changes_with_kind() {
264        let a = vec![line(LineKind::Added, "x")];
265        let b = vec![line(LineKind::Deleted, "x")];
266        assert_ne!(
267            hunk_id("f", &a).expect("hash"),
268            hunk_id("f", &b).expect("hash")
269        );
270    }
271
272    #[test]
273    fn hunk_id_changes_with_file() {
274        let lines = vec![line(LineKind::Added, "x")];
275        assert_ne!(
276            hunk_id("a", &lines).expect("hash"),
277            hunk_id("b", &lines).expect("hash")
278        );
279    }
280
281    #[test]
282    fn header_formats() {
283        let hunk = Hunk {
284            id: HunkId("h".into()),
285            old_start: 10,
286            old_lines: 7,
287            new_start: 10,
288            new_lines: 9,
289            context: String::new(),
290            lines: vec![],
291        };
292        assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
293    }
294
295    #[test]
296    fn content_hash_changes_when_new_text_changes() {
297        let base = FileDiff {
298            path: "f.rs".into(),
299            old_path: None,
300            status: FileStatus::Modified,
301            binary: false,
302            old_text: None,
303            new_text: Some("fn main() {}".into()),
304            hunks: vec![],
305        };
306        let mut changed = base.clone();
307        changed.new_text = Some("fn main() { let x = 1; }".into());
308        assert_ne!(base.content_hash(), changed.content_hash());
309    }
310
311    #[test]
312    fn content_hash_is_stable() {
313        let file = FileDiff {
314            path: "f.rs".into(),
315            old_path: None,
316            status: FileStatus::Modified,
317            binary: false,
318            old_text: None,
319            new_text: Some("same content".into()),
320            hunks: vec![],
321        };
322        assert_eq!(file.content_hash(), file.content_hash());
323    }
324
325    #[test]
326    fn sides_hash_changes_when_old_text_changes() {
327        let base = FileDiff {
328            path: "f.rs".into(),
329            old_path: None,
330            status: FileStatus::Modified,
331            binary: false,
332            old_text: Some("fn main() {}".into()),
333            new_text: Some("fn main() { let x = 1; }".into()),
334            hunks: vec![],
335        };
336        let mut changed = base.clone();
337        changed.old_text = Some("fn main() { unreachable!() }".into());
338        assert_eq!(
339            base.content_hash(),
340            changed.content_hash(),
341            "same new side, same content hash"
342        );
343        assert_ne!(base.sides_hash(), changed.sides_hash());
344    }
345
346    fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
347        DiffModel {
348            files: vec![FileDiff {
349                path: path.to_owned(),
350                old_path: None,
351                status: FileStatus::Modified,
352                binary: false,
353                old_text: Some(old_text.to_owned()),
354                new_text: Some(new_text.to_owned()),
355                hunks: vec![],
356            }],
357        }
358    }
359
360    #[test]
361    fn fingerprint_is_stable_for_identical_models() {
362        let a = one_file_model("f.rs", "old", "new");
363        let b = one_file_model("f.rs", "old", "new");
364        assert_eq!(a.fingerprint(), b.fingerprint());
365    }
366
367    #[test]
368    fn fingerprint_changes_with_content_path_and_file_set() {
369        let base = one_file_model("f.rs", "old", "new");
370        assert_ne!(
371            base.fingerprint(),
372            one_file_model("f.rs", "old", "newer").fingerprint(),
373            "changed side changes the fingerprint"
374        );
375        assert_ne!(
376            base.fingerprint(),
377            one_file_model("g.rs", "old", "new").fingerprint(),
378            "renamed file changes the fingerprint"
379        );
380        let mut grown = base.clone();
381        grown.files.extend(one_file_model("g.rs", "", "x").files);
382        assert_ne!(
383            base.fingerprint(),
384            grown.fingerprint(),
385            "added file changes the fingerprint"
386        );
387    }
388
389    fn model_with_lines() -> DiffModel {
390        DiffModel {
391            files: vec![FileDiff {
392                path: "f.rs".into(),
393                old_path: None,
394                status: FileStatus::Modified,
395                binary: false,
396                old_text: None,
397                new_text: None,
398                hunks: vec![Hunk {
399                    id: HunkId("h".into()),
400                    old_start: 1,
401                    old_lines: 2,
402                    new_start: 1,
403                    new_lines: 2,
404                    context: String::new(),
405                    lines: vec![
406                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
407                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
408                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
409                    ],
410                }],
411            }],
412        }
413    }
414
415    #[test]
416    fn diffstat_counts_added_and_deleted_over_hunks() {
417        // model_with_lines: one context, one deleted, one added line
418        let model = model_with_lines();
419        assert_eq!(model.files[0].diffstat(), (1, 1));
420
421        // two hunks: 2 added + 1 deleted total, context ignored
422        let file = FileDiff {
423            path: "f.rs".into(),
424            old_path: None,
425            status: FileStatus::Modified,
426            binary: false,
427            old_text: None,
428            new_text: None,
429            hunks: vec![
430                Hunk {
431                    id: HunkId("a".into()),
432                    old_start: 1,
433                    old_lines: 1,
434                    new_start: 1,
435                    new_lines: 2,
436                    context: String::new(),
437                    lines: vec![
438                        DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
439                        DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
440                    ],
441                },
442                Hunk {
443                    id: HunkId("b".into()),
444                    old_start: 5,
445                    old_lines: 1,
446                    new_start: 6,
447                    new_lines: 1,
448                    context: String::new(),
449                    lines: vec![
450                        DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
451                        DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
452                    ],
453                },
454            ],
455        };
456        assert_eq!(file.diffstat(), (2, 1));
457    }
458
459    #[test]
460    fn find_line_matches_the_requested_side() {
461        let model = model_with_lines();
462        let new_side = model.find_line("f.rs", 2, false).expect("new side");
463        assert_eq!(new_side.text, "TWO");
464        let old_side = model.find_line("f.rs", 2, true).expect("old side");
465        assert_eq!(old_side.text, "two");
466    }
467
468    #[test]
469    fn find_line_misses_unknown_files_and_lines() {
470        let model = model_with_lines();
471        assert!(model.find_line("nope.rs", 1, false).is_none());
472        assert!(model.find_line("f.rs", 99, false).is_none());
473    }
474
475    #[test]
476    fn content_hash_falls_back_for_none() {
477        let file = FileDiff {
478            path: "f.rs".into(),
479            old_path: None,
480            status: FileStatus::Deleted,
481            binary: false,
482            old_text: None,
483            new_text: None,
484            hunks: vec![],
485        };
486        // must not panic, must return a non-empty string (git hash of empty blob)
487        let hash = file.content_hash();
488        assert!(!hash.is_empty());
489    }
490}