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}
61
62impl FileDiff {
63 pub fn content_hash(&self) -> String {
65 stable_hash(self.new_text.as_deref().unwrap_or("").as_bytes())
66 }
67
68 pub fn diffstat(&self) -> (usize, usize) {
70 let mut added = 0;
71 let mut deleted = 0;
72 for line in self.hunks.iter().flat_map(|h| &h.lines) {
73 match line.kind {
74 LineKind::Added => added += 1,
75 LineKind::Deleted => deleted += 1,
76 LineKind::Context => {}
77 }
78 }
79 (added, deleted)
80 }
81
82 pub fn sides_hash(&self) -> String {
86 let mut bytes = Vec::from(self.old_text.as_deref().unwrap_or("").as_bytes());
87 bytes.push(0);
88 bytes.extend_from_slice(self.new_text.as_deref().unwrap_or("").as_bytes());
89 stable_hash(&bytes)
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum FileStatus {
96 Added,
97 Modified,
98 Deleted,
99 Renamed,
100 Untracked,
101}
102
103impl FileStatus {
104 pub const fn glyph(self) -> char {
106 match self {
107 Self::Added => 'A',
108 Self::Modified => 'M',
109 Self::Deleted => 'D',
110 Self::Renamed => 'R',
111 Self::Untracked => '?',
112 }
113 }
114
115 pub const fn label(self) -> &'static str {
117 match self {
118 Self::Added => "new file",
119 Self::Modified => "modified",
120 Self::Deleted => "deleted",
121 Self::Renamed => "renamed",
122 Self::Untracked => "untracked",
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
130pub struct HunkId(pub String);
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Hunk {
134 pub id: HunkId,
135 pub old_start: u32,
136 pub old_lines: u32,
137 pub new_start: u32,
138 pub new_lines: u32,
139 pub context: String,
143 pub lines: Vec<DiffLine>,
144}
145
146impl Hunk {
147 pub fn header(&self) -> String {
148 format!(
149 "@@ -{},{} +{},{} @@",
150 self.old_start, self.old_lines, self.new_start, self.new_lines
151 )
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum LineKind {
158 Context,
159 Deleted,
160 Added,
161}
162
163impl LineKind {
164 pub const fn origin(self) -> char {
166 match self {
167 Self::Context => ' ',
168 Self::Deleted => '-',
169 Self::Added => '+',
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct DiffLine {
176 pub kind: LineKind,
177 pub old_no: Option<u32>,
178 pub new_no: Option<u32>,
179 pub text: String,
181 pub emphasis: Vec<Range<usize>>,
183 pub moved: bool,
187}
188
189impl DiffLine {
190 pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
191 Self {
192 kind,
193 old_no,
194 new_no,
195 text,
196 emphasis: Vec::new(),
197 moved: false,
198 }
199 }
200}
201
202pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> HunkId {
204 let mut buf = String::new();
205 buf.push_str(file_path);
206 buf.push('\n');
207 for line in lines {
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 HunkId(stable_hash(buf.as_bytes()))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
227 fn stable_hash_is_fnv1a64_and_never_changes() {
228 assert_eq!(stable_hash(b""), "cbf29ce484222325");
229 assert_eq!(stable_hash(b"hello"), "a430d84680aabd0b");
230 }
231
232 #[test]
233 fn file_status_glyph_and_label_cover_all_variants() {
234 assert_eq!(FileStatus::Added.glyph(), 'A');
235 assert_eq!(FileStatus::Modified.glyph(), 'M');
236 assert_eq!(FileStatus::Deleted.glyph(), 'D');
237 assert_eq!(FileStatus::Renamed.glyph(), 'R');
238 assert_eq!(FileStatus::Untracked.glyph(), '?');
239
240 assert_eq!(FileStatus::Added.label(), "new file");
241 assert_eq!(FileStatus::Modified.label(), "modified");
242 assert_eq!(FileStatus::Deleted.label(), "deleted");
243 assert_eq!(FileStatus::Renamed.label(), "renamed");
244 assert_eq!(FileStatus::Untracked.label(), "untracked");
245 }
246
247 fn line(kind: LineKind, text: &str) -> DiffLine {
248 DiffLine::new(kind, None, None, text.to_owned())
249 }
250
251 #[test]
252 fn hunk_id_is_stable() {
253 let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
254 let id1 = hunk_id("src/x.rs", &lines);
255 let id2 = hunk_id("src/x.rs", &lines);
256 assert_eq!(id1, id2);
257 }
258
259 #[test]
260 fn hunk_id_changes_with_content() {
261 let a = vec![line(LineKind::Added, "x")];
262 let b = vec![line(LineKind::Added, "y")];
263 assert_ne!(hunk_id("f", &a), hunk_id("f", &b));
264 }
265
266 #[test]
267 fn hunk_id_changes_with_kind() {
268 let a = vec![line(LineKind::Added, "x")];
269 let b = vec![line(LineKind::Deleted, "x")];
270 assert_ne!(hunk_id("f", &a), hunk_id("f", &b));
271 }
272
273 #[test]
274 fn hunk_id_changes_with_file() {
275 let lines = vec![line(LineKind::Added, "x")];
276 assert_ne!(hunk_id("a", &lines), hunk_id("b", &lines));
277 }
278
279 #[test]
280 fn header_formats() {
281 let hunk = Hunk {
282 id: HunkId("h".into()),
283 old_start: 10,
284 old_lines: 7,
285 new_start: 10,
286 new_lines: 9,
287 context: String::new(),
288 lines: vec![],
289 };
290 assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
291 }
292
293 #[test]
294 fn content_hash_changes_when_new_text_changes() {
295 let base = FileDiff {
296 path: "f.rs".into(),
297 old_path: None,
298 status: FileStatus::Modified,
299 binary: false,
300 old_text: None,
301 new_text: Some("fn main() {}".into()),
302 hunks: vec![],
303 };
304 let mut changed = base.clone();
305 changed.new_text = Some("fn main() { let x = 1; }".into());
306 assert_ne!(base.content_hash(), changed.content_hash());
307 }
308
309 #[test]
310 fn content_hash_is_stable() {
311 let file = FileDiff {
312 path: "f.rs".into(),
313 old_path: None,
314 status: FileStatus::Modified,
315 binary: false,
316 old_text: None,
317 new_text: Some("same content".into()),
318 hunks: vec![],
319 };
320 assert_eq!(file.content_hash(), file.content_hash());
321 }
322
323 #[test]
324 fn sides_hash_changes_when_old_text_changes() {
325 let base = FileDiff {
326 path: "f.rs".into(),
327 old_path: None,
328 status: FileStatus::Modified,
329 binary: false,
330 old_text: Some("fn main() {}".into()),
331 new_text: Some("fn main() { let x = 1; }".into()),
332 hunks: vec![],
333 };
334 let mut changed = base.clone();
335 changed.old_text = Some("fn main() { unreachable!() }".into());
336 assert_eq!(
337 base.content_hash(),
338 changed.content_hash(),
339 "same new side, same content hash"
340 );
341 assert_ne!(base.sides_hash(), changed.sides_hash());
342 }
343
344 fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
345 DiffModel {
346 files: vec![FileDiff {
347 path: path.to_owned(),
348 old_path: None,
349 status: FileStatus::Modified,
350 binary: false,
351 old_text: Some(old_text.to_owned()),
352 new_text: Some(new_text.to_owned()),
353 hunks: vec![],
354 }],
355 }
356 }
357
358 #[test]
359 fn fingerprint_is_stable_for_identical_models() {
360 let a = one_file_model("f.rs", "old", "new");
361 let b = one_file_model("f.rs", "old", "new");
362 assert_eq!(a.fingerprint(), b.fingerprint());
363 }
364
365 #[test]
366 fn fingerprint_changes_with_content_path_and_file_set() {
367 let base = one_file_model("f.rs", "old", "new");
368 assert_ne!(
369 base.fingerprint(),
370 one_file_model("f.rs", "old", "newer").fingerprint(),
371 "changed side changes the fingerprint"
372 );
373 assert_ne!(
374 base.fingerprint(),
375 one_file_model("g.rs", "old", "new").fingerprint(),
376 "renamed file changes the fingerprint"
377 );
378 let mut grown = base.clone();
379 grown.files.extend(one_file_model("g.rs", "", "x").files);
380 assert_ne!(
381 base.fingerprint(),
382 grown.fingerprint(),
383 "added file changes the fingerprint"
384 );
385 }
386
387 fn model_with_lines() -> DiffModel {
388 DiffModel {
389 files: vec![FileDiff {
390 path: "f.rs".into(),
391 old_path: None,
392 status: FileStatus::Modified,
393 binary: false,
394 old_text: None,
395 new_text: None,
396 hunks: vec![Hunk {
397 id: HunkId("h".into()),
398 old_start: 1,
399 old_lines: 2,
400 new_start: 1,
401 new_lines: 2,
402 context: String::new(),
403 lines: vec![
404 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
405 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
406 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
407 ],
408 }],
409 }],
410 }
411 }
412
413 #[test]
414 fn diffstat_counts_added_and_deleted_over_hunks() {
415 let model = model_with_lines();
417 assert_eq!(model.files[0].diffstat(), (1, 1));
418
419 let file = 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![
428 Hunk {
429 id: HunkId("a".into()),
430 old_start: 1,
431 old_lines: 1,
432 new_start: 1,
433 new_lines: 2,
434 context: String::new(),
435 lines: vec![
436 DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
437 DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
438 ],
439 },
440 Hunk {
441 id: HunkId("b".into()),
442 old_start: 5,
443 old_lines: 1,
444 new_start: 6,
445 new_lines: 1,
446 context: String::new(),
447 lines: vec![
448 DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
449 DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
450 ],
451 },
452 ],
453 };
454 assert_eq!(file.diffstat(), (2, 1));
455 }
456
457 #[test]
458 fn find_line_matches_the_requested_side() {
459 let model = model_with_lines();
460 let new_side = model.find_line("f.rs", 2, false).expect("new side");
461 assert_eq!(new_side.text, "TWO");
462 let old_side = model.find_line("f.rs", 2, true).expect("old side");
463 assert_eq!(old_side.text, "two");
464 }
465
466 #[test]
467 fn find_line_misses_unknown_files_and_lines() {
468 let model = model_with_lines();
469 assert!(model.find_line("nope.rs", 1, false).is_none());
470 assert!(model.find_line("f.rs", 99, false).is_none());
471 }
472
473 #[test]
474 fn content_hash_falls_back_for_none() {
475 let file = FileDiff {
476 path: "f.rs".into(),
477 old_path: None,
478 status: FileStatus::Deleted,
479 binary: false,
480 old_text: None,
481 new_text: None,
482 hunks: vec![],
483 };
484 let hash = file.content_hash();
486 assert!(!hash.is_empty());
487 }
488}