1use 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 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 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 pub old_text: Option<String>,
49 pub new_text: Option<String>,
50 pub hunks: Vec<Hunk>,
51}
52
53impl FileDiff {
54 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 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 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 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 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#[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 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 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 pub text: String,
177 pub emphasis: Vec<Range<usize>>,
179 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
198pub 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 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 let model = model_with_lines();
419 assert_eq!(model.files[0].diffstat(), (1, 1));
420
421 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 let hash = file.content_hash();
488 assert!(!hash.is_empty());
489 }
490}