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 {
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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct BlobIds {
65 pub old: Option<String>,
66 pub new: Option<String>,
67}
68
69#[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 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 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 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 Unchanged,
141}
142
143impl FileStatus {
144 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#[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 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 pub text: String,
219 pub emphasis: Vec<Range<usize>>,
221 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
242pub 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#[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}