1use hashbrown::HashMap;
4use serde::Serialize;
5use std::cmp::min;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Chunk<'a> {
10 Equal(&'a str),
11 Delete(&'a str),
12 Insert(&'a str),
13}
14
15#[inline]
17pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
18 if old.is_empty() && new.is_empty() {
19 return Vec::with_capacity(0);
20 }
21 if old.is_empty() {
22 return vec![Chunk::Insert(new)];
23 }
24 if new.is_empty() {
25 return vec![Chunk::Delete(old)];
26 }
27
28 let prefix_byte_len: usize = old
30 .chars()
31 .zip(new.chars())
32 .take_while(|(o, n)| o == n)
33 .map(|(c, _)| c.len_utf8())
34 .sum();
35
36 let old_rest = &old[prefix_byte_len..];
38 let new_rest = &new[prefix_byte_len..];
39
40 let suffix_byte_len: usize = old_rest
41 .chars()
42 .rev()
43 .zip(new_rest.chars().rev())
44 .take_while(|(o, n)| o == n)
45 .map(|(c, _)| c.len_utf8())
46 .sum();
47
48 let old_middle_end = old_rest.len() - suffix_byte_len;
49 let new_middle_end = new_rest.len() - suffix_byte_len;
50
51 let old_middle = &old_rest[..old_middle_end];
52 let new_middle = &new_rest[..new_middle_end];
53
54 let mut result = Vec::with_capacity(old_middle.len() + new_middle.len());
55
56 if prefix_byte_len > 0 {
58 result.push(Chunk::Equal(&old[..prefix_byte_len]));
59 }
60
61 if !old_middle.is_empty() || !new_middle.is_empty() {
63 let old_chars: Vec<char> = old_middle.chars().collect();
64 let new_chars: Vec<char> = new_middle.chars().collect();
65 let old_byte_starts: Vec<usize> = old_middle.char_indices().map(|(idx, _)| idx).collect();
66 let new_byte_starts: Vec<usize> = new_middle.char_indices().map(|(idx, _)| idx).collect();
67 let edits = myers_diff(&old_chars, &new_chars);
68
69 let mut old_pos = 0;
70 let mut new_pos = 0;
71 let mut equal_run_start: Option<usize> = None;
74
75 for edit in edits {
76 match edit {
77 Edit::Equal => {
78 if equal_run_start.is_none() {
79 equal_run_start = Some(old_pos);
80 }
81 old_pos += 1;
82 new_pos += 1;
83 }
84 Edit::Delete => {
85 if let Some(start) = equal_run_start.take() {
87 let byte_start = old_byte_starts[start];
88 let byte_end = old_byte_starts[old_pos];
89 if byte_start < byte_end {
90 result.push(Chunk::Equal(&old_middle[byte_start..byte_end]));
91 }
92 }
93 let Some(ch) = old_chars.get(old_pos).copied() else {
94 break;
95 };
96 let Some(byte_start) = old_byte_starts.get(old_pos).copied() else {
97 break;
98 };
99 let byte_end = byte_start + ch.len_utf8();
100 result.push(Chunk::Delete(&old_middle[byte_start..byte_end]));
101 old_pos += 1;
102 }
103 Edit::Insert => {
104 if let Some(start) = equal_run_start.take() {
108 let byte_start = old_byte_starts[start];
109 let byte_end = if old_pos < old_byte_starts.len() {
110 old_byte_starts[old_pos]
111 } else {
112 old_middle.len()
113 };
114 if byte_start < byte_end {
115 result.push(Chunk::Equal(&old_middle[byte_start..byte_end]));
116 }
117 }
118 let Some(ch) = new_chars.get(new_pos).copied() else {
119 break;
120 };
121 let Some(byte_start) = new_byte_starts.get(new_pos).copied() else {
122 break;
123 };
124 let byte_end = byte_start + ch.len_utf8();
125 result.push(Chunk::Insert(&new_middle[byte_start..byte_end]));
126 new_pos += 1;
127 }
128 }
129 }
130 if let Some(start) = equal_run_start.take() {
132 let byte_start = old_byte_starts[start];
133 let byte_end = old_middle.len();
134 if byte_start < byte_end {
135 result.push(Chunk::Equal(&old_middle[byte_start..byte_end]));
136 }
137 }
138 }
139
140 if suffix_byte_len > 0 {
142 result.push(Chunk::Equal(&old[old.len() - suffix_byte_len..]));
143 }
144
145 result
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum Edit {
150 Equal,
151 Delete,
152 Insert,
153}
154
155#[inline]
159fn advance_matching(old: &[char], new: &[char], mut x: usize, mut y: usize) -> (usize, usize) {
160 while x < old.len() && y < new.len() && old[x] == new[y] {
161 x += 1;
162 y += 1;
163 }
164 (x, y)
165}
166
167#[inline]
172fn backtrack_equal_run(
173 mut x: usize,
174 mut y: usize,
175 move_x: usize,
176 move_y: usize,
177 edits: &mut Vec<Edit>,
178) -> (usize, usize) {
179 while x > move_x && y > move_y {
180 edits.push(Edit::Equal);
181 x -= 1;
182 y -= 1;
183 }
184 (x, y)
185}
186
187#[allow(clippy::cast_sign_loss)]
188fn myers_diff(old: &[char], new: &[char]) -> Vec<Edit> {
189 let n = old.len();
190 let m = new.len();
191
192 if n == 0 {
193 return vec![Edit::Insert; m];
194 }
195 if m == 0 {
196 return vec![Edit::Delete; n];
197 }
198
199 let max_d = n.saturating_add(m).min(i32::MAX as usize);
200 let max_d_i32 = max_d as i32;
201 let mut v = vec![0; 2 * max_d + 1];
202 let mut v_index = vec![0usize; (max_d + 1) * (2 * max_d + 1)];
203 let row_len = 2 * max_d + 1;
204
205 v[max_d] = 0;
206
207 for d in 0..=max_d {
208 let d_i32 = d as i32;
209 let row_start = d * row_len;
210 for k in (-d_i32..=d_i32).step_by(2) {
211 let k_idx = (k + max_d_i32) as usize;
212
213 let x = if k == -d_i32 || (k != d_i32 && v[k_idx - 1] < v[k_idx + 1]) {
214 v[k_idx + 1]
215 } else {
216 v[k_idx - 1] + 1
217 };
218
219 let mut x = x;
220 let mut y = (x as i32 - k) as usize;
221
222 (x, y) = advance_matching(old, new, x, y);
223
224 v[k_idx] = x;
225 v_index[row_start + k_idx] = x;
226
227 if x >= n && y >= m {
228 return backtrack_myers(old, new, &v_index, d, k, max_d);
229 }
230 }
231 }
232
233 vec![]
234}
235
236#[allow(clippy::cast_sign_loss)]
237fn backtrack_myers(
238 old: &[char],
239 new: &[char],
240 v_index: &[usize],
241 d: usize,
242 mut k: i32,
243 max_d: usize,
244) -> Vec<Edit> {
245 let mut edits = Vec::with_capacity(old.len() + new.len());
246 let mut x = old.len();
247 let mut y = new.len();
248 let max_d_i32 = max_d as i32;
249 let row_len = 2 * max_d + 1;
250
251 for cur_d in (0..=d).rev() {
252 if cur_d == 0 {
253 while x > 0 && y > 0 {
254 edits.push(Edit::Equal);
255 x -= 1;
256 y -= 1;
257 }
258 break;
259 }
260
261 let k_idx = (k + max_d_i32) as usize;
262 let prev_row_start = (cur_d - 1) * row_len;
263
264 let cur_d_i32 = cur_d as i32;
265 let prev_k = if k == cur_d_i32.wrapping_neg()
266 || (k != cur_d_i32
267 && v_index[prev_row_start + k_idx - 1] < v_index[prev_row_start + k_idx + 1])
268 {
269 k + 1
270 } else {
271 k - 1
272 };
273
274 let prev_k_idx = (prev_k + max_d_i32) as usize;
275 let prev_x_val = v_index[prev_row_start + prev_k_idx];
276 let prev_y = (prev_x_val as i32 - prev_k) as usize;
277
278 let (move_x, move_y) = if prev_k == k + 1 {
279 (prev_x_val, prev_y + 1)
280 } else {
281 (prev_x_val + 1, prev_y)
282 };
283
284 (x, y) = backtrack_equal_run(x, y, move_x, move_y, &mut edits);
285
286 if prev_k == k + 1 {
287 edits.push(Edit::Insert);
288 y -= 1;
289 } else {
290 edits.push(Edit::Delete);
291 x -= 1;
292 }
293
294 k = prev_k;
295 }
296
297 edits.reverse();
298 edits
299}
300
301#[derive(Debug, Clone)]
303pub struct DiffOptions<'a> {
304 pub context_lines: usize,
305 pub old_label: Option<&'a str>,
306 pub new_label: Option<&'a str>,
307 pub missing_newline_hint: bool,
308}
309
310impl Default for DiffOptions<'_> {
311 fn default() -> Self {
312 Self {
313 context_lines: 3,
314 old_label: None,
315 new_label: None,
316 missing_newline_hint: true,
317 }
318 }
319}
320
321#[derive(Debug, Clone, Serialize)]
323pub struct DiffBundle {
324 pub hunks: Vec<DiffHunk>,
325 pub formatted: String,
326 pub is_empty: bool,
327}
328
329#[derive(Debug, Clone, Serialize)]
331pub struct DiffHunk {
332 pub old_start: usize,
333 pub old_lines: usize,
334 pub new_start: usize,
335 pub new_lines: usize,
336 pub lines: Vec<DiffLine>,
337}
338
339#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
341#[serde(rename_all = "snake_case")]
342pub enum DiffLineKind {
343 Context,
344 Addition,
345 Deletion,
346}
347
348#[derive(Debug, Clone, Serialize)]
350pub struct DiffLine {
351 pub kind: DiffLineKind,
352 pub old_line: Option<u32>,
353 pub new_line: Option<u32>,
354 pub text: String,
355}
356
357pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
359where
360 F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
361{
362 let old_lines_owned = split_lines_with_terminator(old);
363 let new_lines_owned = split_lines_with_terminator(new);
364
365 let old_refs: Vec<&str> = old_lines_owned.iter().map(|s| s.as_str()).collect();
366 let new_refs: Vec<&str> = new_lines_owned.iter().map(|s| s.as_str()).collect();
367
368 let records = collect_line_records(&old_refs, &new_refs);
369 let has_changes = records
370 .iter()
371 .any(|record| matches!(record.kind, DiffLineKind::Addition | DiffLineKind::Deletion));
372
373 let hunks = if has_changes {
374 build_hunks(&records, options.context_lines)
375 } else {
376 Vec::new()
377 };
378
379 let formatted = if hunks.is_empty() {
380 String::new()
381 } else {
382 formatter(&hunks, &options)
383 };
384
385 DiffBundle { hunks, formatted, is_empty: !has_changes }
386}
387
388fn split_lines_with_terminator(text: &str) -> Vec<String> {
389 if text.is_empty() {
390 return Vec::with_capacity(0);
391 }
392
393 let mut lines: Vec<String> = text.split_inclusive('\n').map(|line| line.to_string()).collect();
394
395 if lines.is_empty() {
396 lines.push(text.to_string());
397 }
398
399 lines
400}
401
402#[inline]
403fn collect_line_records<'a>(
404 old_lines: &'a [&'a str],
405 new_lines: &'a [&'a str],
406) -> Vec<LineRecord<'a>> {
407 let (old_encoded, new_encoded) = encode_line_sequences(old_lines, new_lines);
408 let mut records = Vec::with_capacity(old_lines.len() + new_lines.len());
409 let mut old_index = 0u32;
410 let mut new_index = 0u32;
411
412 for chunk in compute_diff_chunks(old_encoded.as_str(), new_encoded.as_str()) {
413 match chunk {
414 Chunk::Equal(text) => {
415 for _ in text.chars() {
416 let old_line = old_index + 1;
417 let new_line = new_index + 1;
418 let line = old_lines[old_index as usize];
419 records.push(LineRecord {
420 kind: DiffLineKind::Context,
421 old_line: Some(old_line),
422 new_line: Some(new_line),
423 text: line,
424 anchor_old: old_line,
425 anchor_new: new_line,
426 });
427 old_index += 1;
428 new_index += 1;
429 }
430 }
431 Chunk::Delete(text) => {
432 for _ in text.chars() {
433 let old_line = old_index + 1;
434 let anchor_new = new_index + 1;
435 let line = old_lines[old_index as usize];
436 records.push(LineRecord {
437 kind: DiffLineKind::Deletion,
438 old_line: Some(old_line),
439 new_line: None,
440 text: line,
441 anchor_old: old_line,
442 anchor_new,
443 });
444 old_index += 1;
445 }
446 }
447 Chunk::Insert(text) => {
448 for _ in text.chars() {
449 let new_line = new_index + 1;
450 let anchor_old = old_index + 1;
451 let line = new_lines[new_index as usize];
452 records.push(LineRecord {
453 kind: DiffLineKind::Addition,
454 old_line: None,
455 new_line: Some(new_line),
456 text: line,
457 anchor_old,
458 anchor_new: new_line,
459 });
460 new_index += 1;
461 }
462 }
463 }
464 }
465
466 records
467}
468
469fn encode_line_sequences<'a>(
470 old_lines: &'a [&'a str],
471 new_lines: &'a [&'a str],
472) -> (String, String) {
473 let mut token_map: HashMap<&'a str, char> = HashMap::new();
474 let mut next_codepoint: u32 = 0;
475
476 let old_encoded = encode_line_list(old_lines, &mut token_map, &mut next_codepoint);
477 let new_encoded = encode_line_list(new_lines, &mut token_map, &mut next_codepoint);
478
479 (old_encoded, new_encoded)
480}
481
482fn encode_line_list<'a>(
483 lines: &'a [&'a str],
484 map: &mut HashMap<&'a str, char>,
485 next_codepoint: &mut u32,
486) -> String {
487 let mut encoded = String::with_capacity(lines.len());
488 for &line in lines {
489 let token = if let Some(&value) = map.get(line) {
490 value
491 } else {
492 let Some(ch) = next_token_char(next_codepoint) else {
493 break;
494 };
495 map.insert(line, ch);
496 ch
497 };
498 encoded.push(token);
499 }
500 encoded
501}
502
503fn next_token_char(counter: &mut u32) -> Option<char> {
504 while *counter <= 0x10FFFF {
505 let candidate = *counter;
506 *counter += 1;
507 if (0xD800..=0xDFFF).contains(&candidate) {
508 continue;
509 }
510 if let Some(ch) = char::from_u32(candidate) {
511 return Some(ch);
512 }
513 }
514 None
515}
516
517#[derive(Debug)]
518struct LineRecord<'a> {
519 kind: DiffLineKind,
520 old_line: Option<u32>,
521 new_line: Option<u32>,
522 text: &'a str,
523 anchor_old: u32,
524 anchor_new: u32,
525}
526
527fn build_hunks(records: &[LineRecord<'_>], context: usize) -> Vec<DiffHunk> {
528 if records.is_empty() {
529 return Vec::new();
530 }
531
532 let ranges = compute_hunk_ranges(records, context);
533 let mut hunks = Vec::with_capacity(ranges.len());
534
535 for (start, end) in ranges {
536 let slice = &records[start..=end];
537
538 let old_start = slice
539 .iter()
540 .filter_map(|r| r.old_line)
541 .min()
542 .or_else(|| slice.iter().map(|r| r.anchor_old).min())
543 .unwrap_or(1) as usize;
544
545 let new_start = slice
546 .iter()
547 .filter_map(|r| r.new_line)
548 .min()
549 .or_else(|| slice.iter().map(|r| r.anchor_new).min())
550 .unwrap_or(1) as usize;
551
552 let old_lines = slice
553 .iter()
554 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Deletion))
555 .count();
556 let new_lines = slice
557 .iter()
558 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Addition))
559 .count();
560
561 let lines = slice
562 .iter()
563 .map(|record| DiffLine {
564 kind: record.kind,
565 old_line: record.old_line,
566 new_line: record.new_line,
567 text: record.text.to_string(),
568 })
569 .collect();
570
571 hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
572 }
573
574 hunks
575}
576
577fn compute_hunk_ranges(records: &[LineRecord<'_>], context: usize) -> Vec<(usize, usize)> {
578 let mut ranges = Vec::with_capacity(4);
579 let mut current_start: Option<usize> = None;
580 let mut current_end: usize = 0;
581
582 for (idx, record) in records.iter().enumerate() {
583 if record.kind != DiffLineKind::Context {
584 let start = idx.saturating_sub(context);
585 let end = min(idx + context, records.len().saturating_sub(1));
586
587 if let Some(existing_start) = current_start {
588 if idx > current_end {
590 ranges.push((existing_start, current_end));
591 current_start = Some(start);
592 current_end = end;
593 } else {
594 if start < existing_start {
595 current_start = Some(start);
596 }
597 if end > current_end {
598 current_end = end;
599 }
600 }
601 } else {
602 current_start = Some(start);
603 current_end = end;
604 }
605 } else if let Some(start) = current_start
606 && idx > current_end
607 {
608 ranges.push((start, current_end));
609 current_start = None;
610 }
611 }
612
613 if let Some(start) = current_start {
614 ranges.push((start, current_end));
615 }
616
617 ranges
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
627 fn chunks_both_empty() {
628 let chunks = compute_diff_chunks("", "");
629 assert!(chunks.is_empty());
630 }
631
632 #[test]
633 fn chunks_old_empty() {
634 let chunks = compute_diff_chunks("", "hello");
635 assert_eq!(chunks, vec![Chunk::Insert("hello")]);
636 }
637
638 #[test]
639 fn chunks_new_empty() {
640 let chunks = compute_diff_chunks("hello", "");
641 assert_eq!(chunks, vec![Chunk::Delete("hello")]);
642 }
643
644 #[test]
645 fn chunks_identical() {
646 let chunks = compute_diff_chunks("abc", "abc");
647 assert_eq!(chunks.len(), 1);
648 assert!(matches!(chunks[0], Chunk::Equal("abc")));
649 }
650
651 #[test]
652 fn chunks_single_insertion() {
653 let chunks = compute_diff_chunks("ac", "abc");
654 assert_eq!(chunks.len(), 3);
656 assert!(matches!(chunks[0], Chunk::Equal("a")));
657 assert!(matches!(chunks[1], Chunk::Insert("b")));
658 assert!(matches!(chunks[2], Chunk::Equal("c")));
659 }
660
661 #[test]
662 fn chunks_single_deletion() {
663 let chunks = compute_diff_chunks("abc", "ac");
664 assert_eq!(chunks.len(), 3);
665 assert!(matches!(chunks[0], Chunk::Equal("a")));
666 assert!(matches!(chunks[1], Chunk::Delete("b")));
667 assert!(matches!(chunks[2], Chunk::Equal("c")));
668 }
669
670 #[test]
671 fn chunks_replacement() {
672 let chunks = compute_diff_chunks("abc", "axc");
673 assert_eq!(chunks.len(), 4);
675 assert!(matches!(chunks[0], Chunk::Equal("a")));
676 assert!(matches!(chunks[1], Chunk::Delete("b")));
677 assert!(matches!(chunks[2], Chunk::Insert("x")));
678 assert!(matches!(chunks[3], Chunk::Equal("c")));
679 }
680
681 #[test]
682 fn chunks_completely_different() {
683 let chunks = compute_diff_chunks("aaa", "bbb");
684 assert!(!chunks.is_empty());
686 let deletes: usize = chunks.iter().filter(|c| matches!(c, Chunk::Delete(_))).count();
688 let inserts: usize = chunks.iter().filter(|c| matches!(c, Chunk::Insert(_))).count();
689 assert!(deletes > 0 || inserts > 0);
690 }
691
692 #[test]
693 fn chunks_multiline() {
694 let old = "line1\nline2\nline3\n";
695 let new = "line1\nline modified\nline3\n";
696 let chunks = compute_diff_chunks(old, new);
697
698 let has_equal = chunks.iter().any(|c| matches!(c, Chunk::Equal(_)));
700 assert!(has_equal);
701
702 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
704 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
705 assert!(has_delete || has_insert);
706 }
707
708 #[test]
709 fn chunks_unicode() {
710 let old = "hello \u{00e9}l\u{00e8}ve";
711 let new = "hello \u{00e9}l\u{00e8}ve you";
712 let chunks = compute_diff_chunks(old, new);
713
714 let prefix = match &chunks[0] {
716 Chunk::Equal(s) => s,
717 _ => panic!("expected Equal prefix"),
718 };
719 assert!(prefix.starts_with("hello "));
720 }
721
722 #[test]
723 fn chunks_append_only() {
724 let old = "a\nb\n";
725 let new = "a\nb\nc\nd\n";
726 let chunks = compute_diff_chunks(old, new);
727 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
728 assert!(has_insert);
729 }
730
731 #[test]
732 fn chunks_remove_only() {
733 let old = "a\nb\nc\n";
734 let new = "a\n";
735 let chunks = compute_diff_chunks(old, new);
736 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
737 assert!(has_delete);
738 }
739
740 fn identity_formatter(hunks: &[DiffHunk], _opts: &DiffOptions<'_>) -> String {
743 hunks
744 .iter()
745 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
746 .collect::<Vec<_>>()
747 .join("")
748 }
749
750 #[test]
751 fn diff_identical_content() {
752 let result = compute_diff("hello\n", "hello\n", DiffOptions::default(), identity_formatter);
753 assert!(result.is_empty);
754 assert!(result.hunks.is_empty());
755 assert!(result.formatted.is_empty());
756 }
757
758 #[test]
759 fn diff_empty_both() {
760 let result = compute_diff("", "", DiffOptions::default(), identity_formatter);
761 assert!(result.is_empty);
762 assert!(result.hunks.is_empty());
763 }
764
765 #[test]
766 fn diff_old_empty() {
767 let result = compute_diff("", "line1\nline2\n", DiffOptions::default(), identity_formatter);
768 assert!(!result.is_empty);
769 assert!(!result.hunks.is_empty());
770 for hunk in &result.hunks {
772 for line in &hunk.lines {
773 assert_eq!(line.kind, DiffLineKind::Addition);
774 }
775 }
776 }
777
778 #[test]
779 fn diff_new_empty() {
780 let result = compute_diff("line1\nline2\n", "", DiffOptions::default(), identity_formatter);
781 assert!(!result.is_empty);
782 assert!(!result.hunks.is_empty());
783 for hunk in &result.hunks {
784 for line in &hunk.lines {
785 assert_eq!(line.kind, DiffLineKind::Deletion);
786 }
787 }
788 }
789
790 #[test]
791 fn diff_single_line_change() {
792 let old = "aaa\nbbb\nccc\n";
793 let new = "aaa\nxxx\nccc\n";
794 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
795
796 assert!(!result.is_empty);
797 assert_eq!(result.hunks.len(), 1);
798
799 let hunk = &result.hunks[0];
800 let kinds: Vec<DiffLineKind> = hunk.lines.iter().map(|l| l.kind).collect();
802 assert!(kinds.contains(&DiffLineKind::Context));
803 assert!(kinds.contains(&DiffLineKind::Deletion));
804 assert!(kinds.contains(&DiffLineKind::Addition));
805 }
806
807 #[test]
808 fn diff_line_numbers() {
809 let old = "line1\nline2\nline3\n";
810 let new = "line1\nline2 modified\nline3\n";
811 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
812
813 let hunk = &result.hunks[0];
814 for line in &hunk.lines {
816 if line.kind == DiffLineKind::Context {
817 assert!(line.old_line.is_some());
818 assert!(line.new_line.is_some());
819 }
820 }
821 for line in &hunk.lines {
823 if line.kind == DiffLineKind::Deletion {
824 assert!(line.old_line.is_some());
825 assert!(line.new_line.is_none());
826 }
827 }
828 for line in &hunk.lines {
830 if line.kind == DiffLineKind::Addition {
831 assert!(line.old_line.is_none());
832 assert!(line.new_line.is_some());
833 }
834 }
835 }
836
837 #[test]
838 fn diff_context_lines_zero() {
839 let old = "a\nb\nc\nd\ne\n";
840 let new = "a\nb\nX\nd\ne\n";
841 let opts = DiffOptions { context_lines: 0, ..DiffOptions::default() };
842 let result = compute_diff(old, new, opts, identity_formatter);
843
844 assert!(!result.is_empty);
845 let hunk = &result.hunks[0];
847 let context_count = hunk.lines.iter().filter(|l| l.kind == DiffLineKind::Context).count();
849 assert!(context_count <= 2); }
851
852 #[test]
853 fn diff_context_lines_large() {
854 let old = "a\nb\nc\nd\ne\n";
855 let new = "a\nb\nX\nd\ne\n";
856 let opts = DiffOptions { context_lines: 10, ..DiffOptions::default() };
857 let result = compute_diff(old, new, opts, identity_formatter);
858
859 assert!(!result.is_empty);
860 let hunk = &result.hunks[0];
862 assert_eq!(hunk.lines.len(), 6);
863 }
864
865 #[test]
866 fn diff_hunk_metadata() {
867 let old = "aaa\nbbb\nccc\n";
868 let new = "aaa\nxxx\nccc\n";
869 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
870
871 let hunk = &result.hunks[0];
872 assert!(hunk.old_start >= 1);
873 assert!(hunk.new_start >= 1);
874 assert!(hunk.old_lines > 0);
875 assert!(hunk.new_lines > 0);
876 assert!(!hunk.lines.is_empty());
877 }
878
879 #[test]
880 fn diff_multiple_hunks() {
881 let old = "a\nb\nc\nd\ne\nf\ng\nh\n";
883 let new = "a\nINSERTED1\nb\nc\nd\ne\nf\ng\nINSERTED2\nh\n";
884 let opts = DiffOptions { context_lines: 1, ..DiffOptions::default() };
885 let result = compute_diff(old, new, opts, identity_formatter);
886
887 assert!(!result.is_empty);
888 assert!(result.hunks.len() >= 2, "expected at least 2 hunks, got {}", result.hunks.len());
889 }
890
891 #[test]
892 fn diff_formatter_called() {
893 let old = "aaa\n";
894 let new = "bbb\n";
895 let mut called = false;
896 let formatter = |hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
897 called = true;
898 hunks
899 .iter()
900 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
901 .collect::<Vec<_>>()
902 .join("")
903 };
904
905 let result = compute_diff(old, new, DiffOptions::default(), formatter);
906 assert!(called);
907 assert!(!result.formatted.is_empty());
908 }
909
910 #[test]
911 fn diff_formatter_not_called_when_empty() {
912 let mut called = false;
913 let formatter = |_hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
914 called = true;
915 String::new()
916 };
917
918 let result = compute_diff("same\n", "same\n", DiffOptions::default(), formatter);
919 assert!(!called);
920 assert!(result.formatted.is_empty());
921 }
922
923 #[test]
924 fn diff_options_labels() {
925 let old = "aaa\n";
926 let new = "bbb\n";
927 let opts = DiffOptions {
928 old_label: Some("old.txt"),
929 new_label: Some("new.txt"),
930 ..DiffOptions::default()
931 };
932 let result = compute_diff(old, new, opts, identity_formatter);
933 assert!(!result.is_empty);
934 assert_eq!(result.hunks.len(), 1);
936 }
937
938 #[test]
939 fn diff_insertion_only() {
940 let old = "line1\nline3\n";
941 let new = "line1\nline2\nline3\n";
942 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
943
944 assert!(!result.is_empty);
945 let additions: Vec<&DiffLine> = result
946 .hunks
947 .iter()
948 .flat_map(|h| h.lines.iter())
949 .filter(|l| l.kind == DiffLineKind::Addition)
950 .collect();
951 assert_eq!(additions.len(), 1);
952 assert_eq!(additions[0].text, "line2\n");
953 }
954
955 #[test]
956 fn diff_deletion_only() {
957 let old = "line1\nline2\nline3\n";
958 let new = "line1\nline3\n";
959 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
960
961 assert!(!result.is_empty);
962 let deletions: Vec<&DiffLine> = result
963 .hunks
964 .iter()
965 .flat_map(|h| h.lines.iter())
966 .filter(|l| l.kind == DiffLineKind::Deletion)
967 .collect();
968 assert_eq!(deletions.len(), 1);
969 assert_eq!(deletions[0].text, "line2\n");
970 }
971
972 #[test]
975 fn diff_bundle_serializes() {
976 let old = "aaa\nbbb\n";
977 let new = "aaa\nxxx\n";
978 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
979
980 let json = serde_json::to_string(&result).unwrap();
981 assert!(json.contains("hunks"));
982 assert!(json.contains("formatted"));
983 assert!(json.contains("is_empty"));
984 }
985
986 #[test]
987 fn diff_hunk_serializes() {
988 let hunk = DiffHunk {
989 old_start: 1,
990 old_lines: 2,
991 new_start: 1,
992 new_lines: 2,
993 lines: vec![DiffLine {
994 kind: DiffLineKind::Context,
995 old_line: Some(1),
996 new_line: Some(1),
997 text: "hello\n".to_string(),
998 }],
999 };
1000 let json = serde_json::to_string(&hunk).unwrap();
1001 assert!(json.contains("old_start"));
1002 assert!(json.contains("context"));
1003 }
1004
1005 #[test]
1006 fn diff_line_kind_serializes() {
1007 assert_eq!(serde_json::to_string(&DiffLineKind::Context).unwrap(), "\"context\"");
1008 assert_eq!(serde_json::to_string(&DiffLineKind::Addition).unwrap(), "\"addition\"");
1009 assert_eq!(serde_json::to_string(&DiffLineKind::Deletion).unwrap(), "\"deletion\"");
1010 }
1011
1012 #[test]
1015 fn chunks_very_long_identical() {
1016 let text = "x".repeat(10_000);
1017 let chunks = compute_diff_chunks(&text, &text);
1018 assert_eq!(chunks.len(), 1);
1019 assert!(matches!(chunks[0], Chunk::Equal(_)));
1020 }
1021
1022 #[test]
1023 fn chunks_single_char_diff() {
1024 let chunks = compute_diff_chunks("a", "b");
1025 assert!(!chunks.is_empty());
1026 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1027 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1028 assert!(has_delete && has_insert);
1029 }
1030
1031 #[test]
1032 fn diff_no_trailing_newline() {
1033 let old = "line1\nline2";
1034 let new = "line1\nline2\n";
1035 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1036 assert!(!result.is_empty);
1037 }
1038
1039 #[test]
1040 fn diff_only_newlines_differ() {
1041 let old = "a\nb\n";
1042 let new = "a\nb";
1043 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1044 assert!(!result.is_empty);
1045 }
1046
1047 #[test]
1048 fn chunks_prefix_suffix_optimization() {
1049 let old = "AAAA BBBB CCCC";
1052 let new = "AAAA DDDD CCCC";
1053 let chunks = compute_diff_chunks(old, new);
1054
1055 assert!(matches!(&chunks[0], Chunk::Equal(s) if *s == "AAAA "));
1057 assert!(matches!(chunks.last().unwrap(), Chunk::Equal(s) if *s == " CCCC"));
1059 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1061 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1062 assert!(has_delete, "expected Delete chunks in middle");
1063 assert!(has_insert, "expected Insert chunks in middle");
1064 }
1065}