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}
180
181impl DiffLine {
182 pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
183 Self {
184 kind,
185 old_no,
186 new_no,
187 text,
188 emphasis: Vec::new(),
189 }
190 }
191}
192
193pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> Result<HunkId, git2::Error> {
196 let mut buf = String::new();
197 buf.push_str(file_path);
198 buf.push('\n');
199 for line in lines {
200 let tag = match line.kind {
204 LineKind::Context => ' ',
205 LineKind::Deleted => '-',
206 LineKind::Added => '+',
207 };
208 buf.push(tag);
209 buf.push_str(&line.text);
210 buf.push('\n');
211 }
212 let oid = git2::Oid::hash_object(git2::ObjectType::Blob, buf.as_bytes())?;
213 Ok(HunkId(oid.to_string()))
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn file_status_glyph_and_label_cover_all_variants() {
222 assert_eq!(FileStatus::Added.glyph(), 'A');
223 assert_eq!(FileStatus::Modified.glyph(), 'M');
224 assert_eq!(FileStatus::Deleted.glyph(), 'D');
225 assert_eq!(FileStatus::Renamed.glyph(), 'R');
226 assert_eq!(FileStatus::Untracked.glyph(), '?');
227
228 assert_eq!(FileStatus::Added.label(), "new file");
229 assert_eq!(FileStatus::Modified.label(), "modified");
230 assert_eq!(FileStatus::Deleted.label(), "deleted");
231 assert_eq!(FileStatus::Renamed.label(), "renamed");
232 assert_eq!(FileStatus::Untracked.label(), "untracked");
233 }
234
235 fn line(kind: LineKind, text: &str) -> DiffLine {
236 DiffLine::new(kind, None, None, text.to_owned())
237 }
238
239 #[test]
240 fn hunk_id_is_stable() {
241 let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
242 let id1 = hunk_id("src/x.rs", &lines).expect("hash");
243 let id2 = hunk_id("src/x.rs", &lines).expect("hash");
244 assert_eq!(id1, id2);
245 }
246
247 #[test]
248 fn hunk_id_changes_with_content() {
249 let a = vec![line(LineKind::Added, "x")];
250 let b = vec![line(LineKind::Added, "y")];
251 assert_ne!(
252 hunk_id("f", &a).expect("hash"),
253 hunk_id("f", &b).expect("hash")
254 );
255 }
256
257 #[test]
258 fn hunk_id_changes_with_kind() {
259 let a = vec![line(LineKind::Added, "x")];
260 let b = vec![line(LineKind::Deleted, "x")];
261 assert_ne!(
262 hunk_id("f", &a).expect("hash"),
263 hunk_id("f", &b).expect("hash")
264 );
265 }
266
267 #[test]
268 fn hunk_id_changes_with_file() {
269 let lines = vec![line(LineKind::Added, "x")];
270 assert_ne!(
271 hunk_id("a", &lines).expect("hash"),
272 hunk_id("b", &lines).expect("hash")
273 );
274 }
275
276 #[test]
277 fn header_formats() {
278 let hunk = Hunk {
279 id: HunkId("h".into()),
280 old_start: 10,
281 old_lines: 7,
282 new_start: 10,
283 new_lines: 9,
284 context: String::new(),
285 lines: vec![],
286 };
287 assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
288 }
289
290 #[test]
291 fn content_hash_changes_when_new_text_changes() {
292 let base = FileDiff {
293 path: "f.rs".into(),
294 old_path: None,
295 status: FileStatus::Modified,
296 binary: false,
297 old_text: None,
298 new_text: Some("fn main() {}".into()),
299 hunks: vec![],
300 };
301 let mut changed = base.clone();
302 changed.new_text = Some("fn main() { let x = 1; }".into());
303 assert_ne!(base.content_hash(), changed.content_hash());
304 }
305
306 #[test]
307 fn content_hash_is_stable() {
308 let file = FileDiff {
309 path: "f.rs".into(),
310 old_path: None,
311 status: FileStatus::Modified,
312 binary: false,
313 old_text: None,
314 new_text: Some("same content".into()),
315 hunks: vec![],
316 };
317 assert_eq!(file.content_hash(), file.content_hash());
318 }
319
320 #[test]
321 fn sides_hash_changes_when_old_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: Some("fn main() {}".into()),
328 new_text: Some("fn main() { let x = 1; }".into()),
329 hunks: vec![],
330 };
331 let mut changed = base.clone();
332 changed.old_text = Some("fn main() { unreachable!() }".into());
333 assert_eq!(
334 base.content_hash(),
335 changed.content_hash(),
336 "same new side, same content hash"
337 );
338 assert_ne!(base.sides_hash(), changed.sides_hash());
339 }
340
341 fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
342 DiffModel {
343 files: vec![FileDiff {
344 path: path.to_owned(),
345 old_path: None,
346 status: FileStatus::Modified,
347 binary: false,
348 old_text: Some(old_text.to_owned()),
349 new_text: Some(new_text.to_owned()),
350 hunks: vec![],
351 }],
352 }
353 }
354
355 #[test]
356 fn fingerprint_is_stable_for_identical_models() {
357 let a = one_file_model("f.rs", "old", "new");
358 let b = one_file_model("f.rs", "old", "new");
359 assert_eq!(a.fingerprint(), b.fingerprint());
360 }
361
362 #[test]
363 fn fingerprint_changes_with_content_path_and_file_set() {
364 let base = one_file_model("f.rs", "old", "new");
365 assert_ne!(
366 base.fingerprint(),
367 one_file_model("f.rs", "old", "newer").fingerprint(),
368 "changed side changes the fingerprint"
369 );
370 assert_ne!(
371 base.fingerprint(),
372 one_file_model("g.rs", "old", "new").fingerprint(),
373 "renamed file changes the fingerprint"
374 );
375 let mut grown = base.clone();
376 grown.files.extend(one_file_model("g.rs", "", "x").files);
377 assert_ne!(
378 base.fingerprint(),
379 grown.fingerprint(),
380 "added file changes the fingerprint"
381 );
382 }
383
384 fn model_with_lines() -> DiffModel {
385 DiffModel {
386 files: vec![FileDiff {
387 path: "f.rs".into(),
388 old_path: None,
389 status: FileStatus::Modified,
390 binary: false,
391 old_text: None,
392 new_text: None,
393 hunks: vec![Hunk {
394 id: HunkId("h".into()),
395 old_start: 1,
396 old_lines: 2,
397 new_start: 1,
398 new_lines: 2,
399 context: String::new(),
400 lines: vec![
401 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
402 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
403 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
404 ],
405 }],
406 }],
407 }
408 }
409
410 #[test]
411 fn diffstat_counts_added_and_deleted_over_hunks() {
412 let model = model_with_lines();
414 assert_eq!(model.files[0].diffstat(), (1, 1));
415
416 let file = FileDiff {
418 path: "f.rs".into(),
419 old_path: None,
420 status: FileStatus::Modified,
421 binary: false,
422 old_text: None,
423 new_text: None,
424 hunks: vec![
425 Hunk {
426 id: HunkId("a".into()),
427 old_start: 1,
428 old_lines: 1,
429 new_start: 1,
430 new_lines: 2,
431 context: String::new(),
432 lines: vec![
433 DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
434 DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
435 ],
436 },
437 Hunk {
438 id: HunkId("b".into()),
439 old_start: 5,
440 old_lines: 1,
441 new_start: 6,
442 new_lines: 1,
443 context: String::new(),
444 lines: vec![
445 DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
446 DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
447 ],
448 },
449 ],
450 };
451 assert_eq!(file.diffstat(), (2, 1));
452 }
453
454 #[test]
455 fn find_line_matches_the_requested_side() {
456 let model = model_with_lines();
457 let new_side = model.find_line("f.rs", 2, false).expect("new side");
458 assert_eq!(new_side.text, "TWO");
459 let old_side = model.find_line("f.rs", 2, true).expect("old side");
460 assert_eq!(old_side.text, "two");
461 }
462
463 #[test]
464 fn find_line_misses_unknown_files_and_lines() {
465 let model = model_with_lines();
466 assert!(model.find_line("nope.rs", 1, false).is_none());
467 assert!(model.find_line("f.rs", 99, false).is_none());
468 }
469
470 #[test]
471 fn content_hash_falls_back_for_none() {
472 let file = FileDiff {
473 path: "f.rs".into(),
474 old_path: None,
475 status: FileStatus::Deleted,
476 binary: false,
477 old_text: None,
478 new_text: None,
479 hunks: vec![],
480 };
481 let hash = file.content_hash();
483 assert!(!hash.is_empty());
484 }
485}