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