1use std::ops::Range;
4
5use serde::{Deserialize, Serialize};
6
7fn 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 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 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 pub old_text: Option<String>,
58 pub new_text: Option<String>,
59 pub hunks: Vec<Hunk>,
60 pub hashes: HashCache,
62}
63
64#[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 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 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 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 pub const fn glyph(self) -> char {
133 match self {
134 Self::Added => 'A',
135 Self::Modified => 'M',
136 Self::Deleted => 'D',
137 Self::Renamed => 'R',
138 Self::Untracked => '?',
139 }
140 }
141
142 pub const fn label(self) -> &'static str {
144 match self {
145 Self::Added => "new file",
146 Self::Modified => "modified",
147 Self::Deleted => "deleted",
148 Self::Renamed => "renamed",
149 Self::Untracked => "untracked",
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
157pub struct HunkId(pub String);
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Hunk {
161 pub id: HunkId,
162 pub old_start: u32,
163 pub old_lines: u32,
164 pub new_start: u32,
165 pub new_lines: u32,
166 pub context: String,
170 pub lines: Vec<DiffLine>,
171}
172
173impl Hunk {
174 pub fn header(&self) -> String {
175 format!(
176 "@@ -{},{} +{},{} @@",
177 self.old_start, self.old_lines, self.new_start, self.new_lines
178 )
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum LineKind {
185 Context,
186 Deleted,
187 Added,
188}
189
190impl LineKind {
191 pub const fn origin(self) -> char {
193 match self {
194 Self::Context => ' ',
195 Self::Deleted => '-',
196 Self::Added => '+',
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct DiffLine {
203 pub kind: LineKind,
204 pub old_no: Option<u32>,
205 pub new_no: Option<u32>,
206 pub text: String,
208 pub emphasis: Vec<Range<usize>>,
210 pub moved: bool,
214}
215
216impl DiffLine {
217 pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
218 Self {
219 kind,
220 old_no,
221 new_no,
222 text,
223 emphasis: Vec::new(),
224 moved: false,
225 }
226 }
227}
228
229pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> HunkId {
231 let mut buf = String::new();
232 buf.push_str(file_path);
233 buf.push('\n');
234 for line in lines {
235 let tag = match line.kind {
236 LineKind::Context => ' ',
237 LineKind::Deleted => '-',
238 LineKind::Added => '+',
239 };
240 buf.push(tag);
241 buf.push_str(&line.text);
242 buf.push('\n');
243 }
244 HunkId(stable_hash(buf.as_bytes()))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
254 fn stable_hash_is_fnv1a64_and_never_changes() {
255 assert_eq!(stable_hash(b""), "cbf29ce484222325");
256 assert_eq!(stable_hash(b"hello"), "a430d84680aabd0b");
257 }
258
259 #[test]
260 fn file_status_glyph_and_label_cover_all_variants() {
261 assert_eq!(FileStatus::Added.glyph(), 'A');
262 assert_eq!(FileStatus::Modified.glyph(), 'M');
263 assert_eq!(FileStatus::Deleted.glyph(), 'D');
264 assert_eq!(FileStatus::Renamed.glyph(), 'R');
265 assert_eq!(FileStatus::Untracked.glyph(), '?');
266
267 assert_eq!(FileStatus::Added.label(), "new file");
268 assert_eq!(FileStatus::Modified.label(), "modified");
269 assert_eq!(FileStatus::Deleted.label(), "deleted");
270 assert_eq!(FileStatus::Renamed.label(), "renamed");
271 assert_eq!(FileStatus::Untracked.label(), "untracked");
272 }
273
274 fn line(kind: LineKind, text: &str) -> DiffLine {
275 DiffLine::new(kind, None, None, text.to_owned())
276 }
277
278 #[test]
279 fn hunk_id_is_stable() {
280 let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
281 let id1 = hunk_id("src/x.rs", &lines);
282 let id2 = hunk_id("src/x.rs", &lines);
283 assert_eq!(id1, id2);
284 }
285
286 #[test]
287 fn hunk_id_changes_with_content() {
288 let a = vec![line(LineKind::Added, "x")];
289 let b = vec![line(LineKind::Added, "y")];
290 assert_ne!(hunk_id("f", &a), hunk_id("f", &b));
291 }
292
293 #[test]
294 fn hunk_id_changes_with_kind() {
295 let a = vec![line(LineKind::Added, "x")];
296 let b = vec![line(LineKind::Deleted, "x")];
297 assert_ne!(hunk_id("f", &a), hunk_id("f", &b));
298 }
299
300 #[test]
301 fn hunk_id_changes_with_file() {
302 let lines = vec![line(LineKind::Added, "x")];
303 assert_ne!(hunk_id("a", &lines), hunk_id("b", &lines));
304 }
305
306 #[test]
307 fn header_formats() {
308 let hunk = Hunk {
309 id: HunkId("h".into()),
310 old_start: 10,
311 old_lines: 7,
312 new_start: 10,
313 new_lines: 9,
314 context: String::new(),
315 lines: vec![],
316 };
317 assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
318 }
319
320 #[test]
321 fn content_hash_changes_when_new_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: None,
328 new_text: Some("fn main() {}".into()),
329 hunks: vec![],
330 hashes: HashCache::default(),
331 };
332 let mut changed = base.clone();
333 changed.new_text = Some("fn main() { let x = 1; }".into());
334 assert_ne!(base.content_hash(), changed.content_hash());
335 }
336
337 #[test]
338 fn content_hash_is_stable() {
339 let file = FileDiff {
340 path: "f.rs".into(),
341 old_path: None,
342 status: FileStatus::Modified,
343 binary: false,
344 old_text: None,
345 new_text: Some("same content".into()),
346 hunks: vec![],
347 hashes: HashCache::default(),
348 };
349 assert_eq!(file.content_hash(), file.content_hash());
350 }
351
352 #[test]
353 fn sides_hash_changes_when_old_text_changes() {
354 let base = FileDiff {
355 path: "f.rs".into(),
356 old_path: None,
357 status: FileStatus::Modified,
358 binary: false,
359 old_text: Some("fn main() {}".into()),
360 new_text: Some("fn main() { let x = 1; }".into()),
361 hunks: vec![],
362 hashes: HashCache::default(),
363 };
364 let mut changed = base.clone();
365 changed.old_text = Some("fn main() { unreachable!() }".into());
366 assert_eq!(
367 base.content_hash(),
368 changed.content_hash(),
369 "same new side, same content hash"
370 );
371 assert_ne!(base.sides_hash(), changed.sides_hash());
372 }
373
374 fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
375 DiffModel {
376 files: vec![FileDiff {
377 path: path.to_owned(),
378 old_path: None,
379 status: FileStatus::Modified,
380 binary: false,
381 old_text: Some(old_text.to_owned()),
382 new_text: Some(new_text.to_owned()),
383 hunks: vec![],
384 hashes: HashCache::default(),
385 }],
386 }
387 }
388
389 #[test]
390 fn fingerprint_is_stable_for_identical_models() {
391 let a = one_file_model("f.rs", "old", "new");
392 let b = one_file_model("f.rs", "old", "new");
393 assert_eq!(a.fingerprint(), b.fingerprint());
394 }
395
396 #[test]
397 fn fingerprint_changes_with_content_path_and_file_set() {
398 let base = one_file_model("f.rs", "old", "new");
399 assert_ne!(
400 base.fingerprint(),
401 one_file_model("f.rs", "old", "newer").fingerprint(),
402 "changed side changes the fingerprint"
403 );
404 assert_ne!(
405 base.fingerprint(),
406 one_file_model("g.rs", "old", "new").fingerprint(),
407 "renamed file changes the fingerprint"
408 );
409 let mut grown = base.clone();
410 grown.files.extend(one_file_model("g.rs", "", "x").files);
411 assert_ne!(
412 base.fingerprint(),
413 grown.fingerprint(),
414 "added file changes the fingerprint"
415 );
416 }
417
418 fn model_with_lines() -> DiffModel {
419 DiffModel {
420 files: vec![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![Hunk {
428 id: HunkId("h".into()),
429 old_start: 1,
430 old_lines: 2,
431 new_start: 1,
432 new_lines: 2,
433 context: String::new(),
434 lines: vec![
435 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
436 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
437 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
438 ],
439 }],
440 hashes: HashCache::default(),
441 }],
442 }
443 }
444
445 #[test]
446 fn diffstat_counts_added_and_deleted_over_hunks() {
447 let model = model_with_lines();
449 assert_eq!(model.files[0].diffstat(), (1, 1));
450
451 let file = FileDiff {
453 path: "f.rs".into(),
454 old_path: None,
455 status: FileStatus::Modified,
456 binary: false,
457 old_text: None,
458 new_text: None,
459 hunks: vec![
460 Hunk {
461 id: HunkId("a".into()),
462 old_start: 1,
463 old_lines: 1,
464 new_start: 1,
465 new_lines: 2,
466 context: String::new(),
467 lines: vec![
468 DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
469 DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
470 ],
471 },
472 Hunk {
473 id: HunkId("b".into()),
474 old_start: 5,
475 old_lines: 1,
476 new_start: 6,
477 new_lines: 1,
478 context: String::new(),
479 lines: vec![
480 DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
481 DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
482 ],
483 },
484 ],
485 hashes: HashCache::default(),
486 };
487 assert_eq!(file.diffstat(), (2, 1));
488 }
489
490 #[test]
491 fn find_line_matches_the_requested_side() {
492 let model = model_with_lines();
493 let new_side = model.find_line("f.rs", 2, false).expect("new side");
494 assert_eq!(new_side.text, "TWO");
495 let old_side = model.find_line("f.rs", 2, true).expect("old side");
496 assert_eq!(old_side.text, "two");
497 }
498
499 #[test]
500 fn find_line_misses_unknown_files_and_lines() {
501 let model = model_with_lines();
502 assert!(model.find_line("nope.rs", 1, false).is_none());
503 assert!(model.find_line("f.rs", 99, false).is_none());
504 }
505
506 #[test]
507 fn content_hash_falls_back_for_none() {
508 let file = FileDiff {
509 path: "f.rs".into(),
510 old_path: None,
511 status: FileStatus::Deleted,
512 binary: false,
513 old_text: None,
514 new_text: None,
515 hunks: vec![],
516 hashes: HashCache::default(),
517 };
518 let hash = file.content_hash();
520 assert!(!hash.is_empty());
521 }
522}