1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DiffHunk {
10 pub old_start: usize,
11 pub old_count: usize,
12 pub new_start: usize,
13 pub new_count: usize,
14 pub lines: Vec<DiffLine>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DiffLine {
20 pub kind: DiffLineKind,
21 pub content: String,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum DiffLineKind {
28 Context,
29 Add,
30 Remove,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FileDiff {
36 pub path: String,
37 pub status: FileStatus,
38 pub hunks: Vec<DiffHunk>,
39 pub old_hash: Option<String>,
40 pub new_hash: Option<String>,
41 pub is_binary: bool,
42 pub additions: usize,
43 pub deletions: usize,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum FileStatus {
50 Added,
51 Modified,
52 Deleted,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DiffStat {
58 pub path: String,
59 pub additions: usize,
60 pub deletions: usize,
61 pub status: FileStatus,
62}
63
64const CONTEXT_LINES: usize = 3;
66
67pub fn myers_diff<'a>(old: &'a [&str], new: &'a [&str]) -> Vec<DiffOp<'a>> {
71 let n = old.len();
72 let m = new.len();
73
74 if n == 0 && m == 0 {
75 return vec![];
76 }
77 if n == 0 {
78 return new.iter().map(|l| DiffOp::Insert(l)).collect();
79 }
80 if m == 0 {
81 return old.iter().map(|l| DiffOp::Delete(l)).collect();
82 }
83
84 let max = n + m;
85 let size = 2 * max + 1;
87 let mut v = vec![0usize; size];
88 let mut trace: Vec<Vec<usize>> = Vec::new();
89
90 'outer: for d in 0..=(max as isize) {
91 trace.push(v.clone());
92 let mut new_v = v.clone();
93
94 let k_min = -d;
95 let k_max = d;
96 let mut k = k_min;
97 while k <= k_max {
98 let idx = (k + max as isize) as usize;
99 let mut x = if k == -d
100 || (k != d
101 && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
102 {
103 v[((k + 1) + max as isize) as usize]
104 } else {
105 v[((k - 1) + max as isize) as usize] + 1
106 };
107
108 let mut y = (x as isize - k) as usize;
109
110 while x < n && y < m && old[x] == new[y] {
112 x += 1;
113 y += 1;
114 }
115
116 new_v[idx] = x;
117
118 if x >= n && y >= m {
119 v = new_v;
120 trace.push(v.clone());
121 break 'outer;
122 }
123
124 k += 2;
125 }
126 v = new_v;
127 }
128
129 backtrack(&trace, n, m, max, old, new)
131}
132
133#[derive(Debug, Clone)]
135pub enum DiffOp<'a> {
136 Equal(&'a str),
137 Insert(&'a str),
138 Delete(&'a str),
139}
140
141fn backtrack<'a>(
142 trace: &[Vec<usize>],
143 n: usize,
144 m: usize,
145 max: usize,
146 old: &'a [&str],
147 new: &'a [&str],
148) -> Vec<DiffOp<'a>> {
149 let mut ops = Vec::new();
150 let mut x = n;
151 let mut y = m;
152
153 for d in (0..trace.len().saturating_sub(1)).rev() {
154 let v = &trace[d];
155 let k = x as isize - y as isize;
156
157 let (prev_x, prev_y) = if d == 0 {
158 (0usize, 0usize)
160 } else {
161 let prev_k = if k == -(d as isize)
162 || (k != d as isize
163 && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
164 {
165 k + 1
166 } else {
167 k - 1
168 };
169
170 let px = v[(prev_k + max as isize) as usize];
171 let py = (px as isize - prev_k) as usize;
172 (px, py)
173 };
174
175 while x > prev_x && y > prev_y {
177 x -= 1;
178 y -= 1;
179 ops.push(DiffOp::Equal(old[x]));
180 }
181
182 if d > 0 {
183 let prev_k = if k == -(d as isize)
184 || (k != d as isize
185 && v[((k - 1) + max as isize) as usize] < v[((k + 1) + max as isize) as usize])
186 {
187 k + 1
188 } else {
189 k - 1
190 };
191 let prev_x = v[(prev_k + max as isize) as usize];
192
193 if x == prev_x {
194 y -= 1;
196 ops.push(DiffOp::Insert(new[y]));
197 } else {
198 x -= 1;
200 ops.push(DiffOp::Delete(old[x]));
201 }
202 }
203 }
204
205 ops.reverse();
206 ops
207}
208
209pub fn ops_to_hunks(ops: &[DiffOp], context: usize) -> Vec<DiffHunk> {
211 if ops.is_empty() {
212 return vec![];
213 }
214
215 let mut lines: Vec<(usize, usize, DiffLineKind, String)> = Vec::new();
217 let mut old_line = 0usize;
218 let mut new_line = 0usize;
219
220 for op in ops {
221 match op {
222 DiffOp::Equal(s) => {
223 lines.push((old_line, new_line, DiffLineKind::Context, s.to_string()));
224 old_line += 1;
225 new_line += 1;
226 }
227 DiffOp::Delete(s) => {
228 lines.push((old_line, new_line, DiffLineKind::Remove, s.to_string()));
229 old_line += 1;
230 }
231 DiffOp::Insert(s) => {
232 lines.push((old_line, new_line, DiffLineKind::Add, s.to_string()));
233 new_line += 1;
234 }
235 }
236 }
237
238 let change_indices: Vec<usize> = lines
240 .iter()
241 .enumerate()
242 .filter(|(_, (_, _, kind, _))| *kind != DiffLineKind::Context)
243 .map(|(i, _)| i)
244 .collect();
245
246 if change_indices.is_empty() {
247 return vec![];
248 }
249
250 let mut groups: Vec<(usize, usize)> = Vec::new();
252 let mut group_start = change_indices[0];
253 let mut group_end = change_indices[0];
254
255 for &idx in &change_indices[1..] {
256 if idx <= group_end + context * 2 + 1 {
257 group_end = idx;
258 } else {
259 groups.push((group_start, group_end));
260 group_start = idx;
261 group_end = idx;
262 }
263 }
264 groups.push((group_start, group_end));
265
266 let mut hunks = Vec::new();
268 for (start, end) in groups {
269 let hunk_start = start.saturating_sub(context);
270 let hunk_end = (end + context + 1).min(lines.len());
271
272 let mut hunk_lines = Vec::new();
273 let mut old_start = 0;
274 let mut new_start = 0;
275 let mut old_count = 0;
276 let mut new_count = 0;
277 let mut first = true;
278
279 for line in lines.iter().take(hunk_end).skip(hunk_start) {
280 let (ol, nl, kind, content) = line;
281 if first {
282 old_start = *ol;
283 new_start = *nl;
284 first = false;
285 }
286 match kind {
287 DiffLineKind::Context => {
288 old_count += 1;
289 new_count += 1;
290 }
291 DiffLineKind::Add => {
292 new_count += 1;
293 }
294 DiffLineKind::Remove => {
295 old_count += 1;
296 }
297 }
298 hunk_lines.push(DiffLine {
299 kind: *kind,
300 content: content.clone(),
301 });
302 }
303
304 hunks.push(DiffHunk {
305 old_start: old_start + 1, old_count,
307 new_start: new_start + 1, new_count,
309 lines: hunk_lines,
310 });
311 }
312
313 hunks
314}
315
316pub fn diff_text(old: &str, new: &str) -> Vec<DiffHunk> {
318 let old_lines: Vec<&str> = old.lines().collect();
319 let new_lines: Vec<&str> = new.lines().collect();
320 let ops = myers_diff(&old_lines, &new_lines);
321 ops_to_hunks(&ops, CONTEXT_LINES)
322}
323
324pub fn diff_blobs(
326 path: &str,
327 old_content: Option<&[u8]>,
328 new_content: Option<&[u8]>,
329 old_hash: Option<String>,
330 new_hash: Option<String>,
331) -> FileDiff {
332 let status = match (old_content, new_content) {
333 (None, Some(_)) => FileStatus::Added,
334 (Some(_), None) => FileStatus::Deleted,
335 _ => FileStatus::Modified,
336 };
337
338 let old_text = old_content.and_then(|c| std::str::from_utf8(c).ok());
340 let new_text = new_content.and_then(|c| std::str::from_utf8(c).ok());
341
342 let is_binary = (old_content.is_some() && old_text.is_none())
343 || (new_content.is_some() && new_text.is_none());
344
345 if is_binary {
346 return FileDiff {
347 path: path.to_string(),
348 status,
349 hunks: vec![],
350 old_hash,
351 new_hash,
352 is_binary: true,
353 additions: 0,
354 deletions: 0,
355 };
356 }
357
358 let old_str = old_text.unwrap_or("");
359 let new_str = new_text.unwrap_or("");
360
361 let hunks = diff_text(old_str, new_str);
362
363 let mut additions = 0;
364 let mut deletions = 0;
365 for hunk in &hunks {
366 for line in &hunk.lines {
367 match line.kind {
368 DiffLineKind::Add => additions += 1,
369 DiffLineKind::Remove => deletions += 1,
370 DiffLineKind::Context => {}
371 }
372 }
373 }
374
375 FileDiff {
376 path: path.to_string(),
377 status,
378 hunks,
379 old_hash,
380 new_hash,
381 is_binary: false,
382 additions,
383 deletions,
384 }
385}
386
387pub fn collect_tree_files(
389 tree: &crate::core::Tree,
390 store: &crate::storage::ObjectStore,
391 prefix: &str,
392) -> Result<Vec<(String, crate::core::ObjectHash)>, String> {
393 let mut files = Vec::new();
394
395 for entry in &tree.entries {
396 let path = if prefix.is_empty() {
397 entry.name.clone()
398 } else {
399 format!("{}/{}", prefix, entry.name)
400 };
401
402 match entry.object_type.as_str() {
403 "blob" => {
404 files.push((path, entry.hash.clone()));
405 }
406 "tree" => {
407 let subtree = match store.read(&entry.hash)? {
408 crate::core::Object::Tree(t) => t,
409 _ => return Err(format!("Expected tree at {}", path)),
410 };
411 files.extend(collect_tree_files(&subtree, store, &path)?);
412 }
413 _ => {}
414 }
415 }
416
417 Ok(files)
418}
419
420pub fn diff_trees(
422 old_tree: &crate::core::Tree,
423 new_tree: &crate::core::Tree,
424 store: &crate::storage::ObjectStore,
425) -> Result<Vec<FileDiff>, String> {
426 let old_files = collect_tree_files(old_tree, store, "")?;
427 let new_files = collect_tree_files(new_tree, store, "")?;
428
429 let mut old_map: std::collections::HashMap<String, crate::core::ObjectHash> =
430 old_files.into_iter().collect();
431 let new_map: std::collections::HashMap<String, crate::core::ObjectHash> =
432 new_files.into_iter().collect();
433
434 let mut diffs = Vec::new();
435
436 for (path, new_hash) in &new_map {
438 if let Some(old_hash) = old_map.remove(path) {
439 if old_hash != *new_hash {
441 let old_blob = read_blob(store, &old_hash)?;
442 let new_blob = read_blob(store, new_hash)?;
443 diffs.push(diff_blobs(
444 path,
445 Some(&old_blob),
446 Some(&new_blob),
447 Some(old_hash.to_string()),
448 Some(new_hash.to_string()),
449 ));
450 }
451 } else {
452 let new_blob = read_blob(store, new_hash)?;
454 diffs.push(diff_blobs(
455 path,
456 None,
457 Some(&new_blob),
458 None,
459 Some(new_hash.to_string()),
460 ));
461 }
462 }
463
464 for (path, old_hash) in &old_map {
466 let old_blob = read_blob(store, old_hash)?;
467 diffs.push(diff_blobs(
468 path,
469 Some(&old_blob),
470 None,
471 Some(old_hash.to_string()),
472 None,
473 ));
474 }
475
476 diffs.sort_by(|a, b| a.path.cmp(&b.path));
478
479 Ok(diffs)
480}
481
482fn read_blob(
483 store: &crate::storage::ObjectStore,
484 hash: &crate::core::ObjectHash,
485) -> Result<Vec<u8>, String> {
486 match store.read(hash)? {
487 crate::core::Object::Blob(b) => Ok(b.content),
488 _ => Err(format!("Expected blob object for hash {}", hash)),
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct WordDiffSegment {
495 pub kind: DiffLineKind,
496 pub text: String,
497}
498
499fn tokenize_words(line: &str) -> Vec<&str> {
501 let mut tokens = Vec::new();
502 let mut chars = line.char_indices().peekable();
503 while let Some(&(start, ch)) = chars.peek() {
504 if ch.is_alphanumeric() || ch == '_' {
505 let mut end = start;
507 while let Some(&(i, c)) = chars.peek() {
508 if c.is_alphanumeric() || c == '_' {
509 end = i + c.len_utf8();
510 chars.next();
511 } else {
512 break;
513 }
514 }
515 tokens.push(&line[start..end]);
516 } else {
517 tokens.push(&line[start..start + ch.len_utf8()]);
519 chars.next();
520 }
521 }
522 tokens
523}
524
525pub fn word_diff_line(old_line: &str, new_line: &str) -> Vec<WordDiffSegment> {
529 let old_tokens = tokenize_words(old_line);
530 let new_tokens = tokenize_words(new_line);
531
532 let ops = myers_diff(&old_tokens, &new_tokens);
533 let mut segments: Vec<WordDiffSegment> = Vec::new();
534
535 for op in &ops {
536 let (kind, text) = match op {
537 DiffOp::Equal(s) => (DiffLineKind::Context, *s),
538 DiffOp::Delete(s) => (DiffLineKind::Remove, *s),
539 DiffOp::Insert(s) => (DiffLineKind::Add, *s),
540 };
541 if let Some(last) = segments.last_mut() {
543 if last.kind == kind {
544 last.text.push_str(text);
545 continue;
546 }
547 }
548 segments.push(WordDiffSegment {
549 kind,
550 text: text.to_string(),
551 });
552 }
553 segments
554}
555
556pub fn annotate_hunk_word_diff(hunk: &DiffHunk) -> Vec<(DiffLine, Option<Vec<WordDiffSegment>>)> {
561 let mut result: Vec<(DiffLine, Option<Vec<WordDiffSegment>>)> = Vec::new();
562 let lines = &hunk.lines;
563 let mut i = 0;
564
565 while i < lines.len() {
566 if lines[i].kind == DiffLineKind::Remove {
567 let remove_start = i;
569 while i < lines.len() && lines[i].kind == DiffLineKind::Remove {
570 i += 1;
571 }
572 let remove_end = i;
573 let add_start = i;
575 while i < lines.len() && lines[i].kind == DiffLineKind::Add {
576 i += 1;
577 }
578 let add_end = i;
579
580 let removes = &lines[remove_start..remove_end];
581 let adds = &lines[add_start..add_end];
582 let pairs = removes.len().min(adds.len());
583
584 for j in 0..pairs {
586 let segs = word_diff_line(&removes[j].content, &adds[j].content);
587 result.push((removes[j].clone(), Some(segs.clone())));
588 result.push((adds[j].clone(), Some(segs)));
589 }
590 for remove in removes.iter().skip(pairs) {
592 result.push((remove.clone(), None));
593 }
594 for add in adds.iter().skip(pairs) {
596 result.push((add.clone(), None));
597 }
598 } else {
599 result.push((lines[i].clone(), None));
600 i += 1;
601 }
602 }
603 result
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn test_empty_diff() {
612 let hunks = diff_text("", "");
613 assert!(hunks.is_empty());
614 }
615
616 #[test]
617 fn test_add_lines() {
618 let hunks = diff_text("", "hello\nworld\n");
619 assert_eq!(hunks.len(), 1);
620 assert_eq!(hunks[0].lines.len(), 2);
621 assert!(hunks[0].lines.iter().all(|l| l.kind == DiffLineKind::Add));
622 }
623
624 #[test]
625 fn test_delete_lines() {
626 let hunks = diff_text("hello\nworld\n", "");
627 assert_eq!(hunks.len(), 1);
628 assert!(hunks[0]
629 .lines
630 .iter()
631 .all(|l| l.kind == DiffLineKind::Remove));
632 }
633
634 #[test]
635 fn test_modify_line() {
636 let hunks = diff_text("hello\nworld\n", "hello\nearth\n");
637 assert_eq!(hunks.len(), 1);
638 let changes: Vec<_> = hunks[0]
639 .lines
640 .iter()
641 .filter(|l| l.kind != DiffLineKind::Context)
642 .collect();
643 assert_eq!(changes.len(), 2); }
645
646 #[test]
647 fn test_identical_text() {
648 let hunks = diff_text("hello\nworld\n", "hello\nworld\n");
649 assert!(hunks.is_empty());
650 }
651
652 #[test]
653 fn test_additions_count() {
654 let diff = diff_blobs("test.txt", Some(b"a\nb\n"), Some(b"a\nb\nc\n"), None, None);
655 assert_eq!(diff.additions, 1);
656 assert_eq!(diff.deletions, 0);
657 assert_eq!(diff.status, FileStatus::Modified);
658 }
659
660 #[test]
661 fn test_new_file_status() {
662 let diff = diff_blobs("test.txt", None, Some(b"hello\n"), None, None);
663 assert_eq!(diff.status, FileStatus::Added);
664 }
665
666 #[test]
667 fn test_deleted_file_status() {
668 let diff = diff_blobs("test.txt", Some(b"hello\n"), None, None, None);
669 assert_eq!(diff.status, FileStatus::Deleted);
670 }
671
672 #[test]
673 fn test_binary_detection() {
674 let diff = diff_blobs("img.png", Some(b"\x89PNG\r\n\x1a\n\x00"), None, None, None);
675 assert!(diff.is_binary);
676 }
677
678 #[test]
679 fn test_multi_hunk() {
680 let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n";
681 let new = "1\n2\n3\n4\n5\n6\n7\nEIGHT\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nTWENTY\n";
682 let hunks = diff_text(old, new);
683 assert!(hunks.len() >= 2);
685 }
686
687 #[test]
688 fn test_tokenize_words() {
689 let tokens = tokenize_words("hello world_foo + bar");
690 assert_eq!(
691 tokens,
692 vec!["hello", " ", "world_foo", " ", "+", " ", "bar"]
693 );
694 }
695
696 #[test]
697 fn test_word_diff_simple() {
698 let segs = word_diff_line("the quick brown fox", "the slow brown fox");
699 let kinds: Vec<_> = segs.iter().map(|s| s.kind).collect();
701 assert!(kinds.contains(&DiffLineKind::Remove));
702 assert!(kinds.contains(&DiffLineKind::Add));
703 assert!(kinds.contains(&DiffLineKind::Context));
704 }
705
706 #[test]
707 fn test_word_diff_identical() {
708 let segs = word_diff_line("no change", "no change");
709 assert!(segs.iter().all(|s| s.kind == DiffLineKind::Context));
710 }
711}