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