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 mut lines: Vec<String> = text.split_inclusive('\n').map(|line| line.to_string()).collect();
401
402 if lines.is_empty() {
403 lines.push(text.to_string());
404 }
405
406 lines
407}
408
409#[inline]
410fn collect_line_records<'a>(old_lines: &'a [&'a str], new_lines: &'a [&'a str]) -> Vec<LineRecord<'a>> {
411 let (old_encoded, new_encoded) = encode_line_sequences(old_lines, new_lines);
412 let mut records = Vec::with_capacity(old_lines.len() + new_lines.len());
413 let mut old_index = 0u32;
414 let mut new_index = 0u32;
415
416 for chunk in compute_diff_chunks(old_encoded.as_str(), new_encoded.as_str()) {
417 match chunk {
418 Chunk::Equal(text) => {
419 for _ in text.chars() {
420 let old_line = old_index + 1;
421 let new_line = new_index + 1;
422 let line = old_lines[old_index as usize];
423 records.push(LineRecord {
424 kind: DiffLineKind::Context,
425 old_line: Some(old_line),
426 new_line: Some(new_line),
427 text: line,
428 anchor_old: old_line,
429 anchor_new: new_line,
430 });
431 old_index += 1;
432 new_index += 1;
433 }
434 }
435 Chunk::Delete(text) => {
436 for _ in text.chars() {
437 let old_line = old_index + 1;
438 let anchor_new = new_index + 1;
439 let line = old_lines[old_index as usize];
440 records.push(LineRecord {
441 kind: DiffLineKind::Deletion,
442 old_line: Some(old_line),
443 new_line: None,
444 text: line,
445 anchor_old: old_line,
446 anchor_new,
447 });
448 old_index += 1;
449 }
450 }
451 Chunk::Insert(text) => {
452 for _ in text.chars() {
453 let new_line = new_index + 1;
454 let anchor_old = old_index + 1;
455 let line = new_lines[new_index as usize];
456 records.push(LineRecord {
457 kind: DiffLineKind::Addition,
458 old_line: None,
459 new_line: Some(new_line),
460 text: line,
461 anchor_old,
462 anchor_new: new_line,
463 });
464 new_index += 1;
465 }
466 }
467 }
468 }
469
470 records
471}
472
473fn encode_line_sequences<'a>(old_lines: &'a [&'a str], new_lines: &'a [&'a str]) -> (String, String) {
474 let mut token_map: HashMap<&'a str, char> = HashMap::new();
475 let mut next_codepoint: u32 = 0;
476
477 let old_encoded = encode_line_list(old_lines, &mut token_map, &mut next_codepoint);
478 let new_encoded = encode_line_list(new_lines, &mut token_map, &mut next_codepoint);
479
480 (old_encoded, new_encoded)
481}
482
483fn encode_line_list<'a>(lines: &'a [&'a str], map: &mut HashMap<&'a str, char>, next_codepoint: &mut u32) -> String {
484 let mut encoded = String::with_capacity(lines.len());
485 for &line in lines {
486 let token = if let Some(&value) = map.get(line) {
487 value
488 } else {
489 let Some(ch) = next_token_char(next_codepoint) else {
490 break;
491 };
492 map.insert(line, ch);
493 ch
494 };
495 encoded.push(token);
496 }
497 encoded
498}
499
500fn next_token_char(counter: &mut u32) -> Option<char> {
501 while *counter <= 0x10FFFF {
502 let candidate = *counter;
503 *counter += 1;
504 if (0xD800..=0xDFFF).contains(&candidate) {
505 continue;
506 }
507 if let Some(ch) = char::from_u32(candidate) {
508 return Some(ch);
509 }
510 }
511 None
512}
513
514#[derive(Debug)]
515struct LineRecord<'a> {
516 kind: DiffLineKind,
517 old_line: Option<u32>,
518 new_line: Option<u32>,
519 text: &'a str,
520 anchor_old: u32,
521 anchor_new: u32,
522}
523
524fn build_hunks(records: &[LineRecord<'_>], context: usize) -> Vec<DiffHunk> {
525 if records.is_empty() {
526 return Vec::new();
527 }
528
529 let ranges = compute_hunk_ranges(records, context);
530 let mut hunks = Vec::with_capacity(ranges.len());
531
532 for (start, end) in ranges {
533 let slice = &records[start..=end];
534
535 let old_start = slice
536 .iter()
537 .filter_map(|r| r.old_line)
538 .min()
539 .or_else(|| slice.iter().map(|r| r.anchor_old).min())
540 .unwrap_or(1) as usize;
541
542 let new_start = slice
543 .iter()
544 .filter_map(|r| r.new_line)
545 .min()
546 .or_else(|| slice.iter().map(|r| r.anchor_new).min())
547 .unwrap_or(1) as usize;
548
549 let old_lines = slice
550 .iter()
551 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Deletion))
552 .count();
553 let new_lines = slice
554 .iter()
555 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Addition))
556 .count();
557
558 let lines = slice
559 .iter()
560 .map(|record| DiffLine {
561 kind: record.kind,
562 old_line: record.old_line,
563 new_line: record.new_line,
564 text: record.text.to_string(),
565 })
566 .collect();
567
568 hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
569 }
570
571 hunks
572}
573
574fn compute_hunk_ranges(records: &[LineRecord<'_>], context: usize) -> Vec<(usize, usize)> {
575 let mut ranges = Vec::with_capacity(4);
576 let mut current_start: Option<usize> = None;
577 let mut current_end: usize = 0;
578
579 for (idx, record) in records.iter().enumerate() {
580 if record.kind != DiffLineKind::Context {
581 let start = idx.saturating_sub(context);
582 let end = min(idx + context, records.len().saturating_sub(1));
583
584 if let Some(existing_start) = current_start {
585 if idx > current_end {
587 ranges.push((existing_start, current_end));
588 current_start = Some(start);
589 current_end = end;
590 } else {
591 if start < existing_start {
592 current_start = Some(start);
593 }
594 if end > current_end {
595 current_end = end;
596 }
597 }
598 } else {
599 current_start = Some(start);
600 current_end = end;
601 }
602 } else if let Some(start) = current_start
603 && idx > current_end
604 {
605 ranges.push((start, current_end));
606 current_start = None;
607 }
608 }
609
610 if let Some(start) = current_start {
611 ranges.push((start, current_end));
612 }
613
614 ranges
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
624 fn chunks_both_empty() {
625 let chunks = compute_diff_chunks("", "");
626 assert!(chunks.is_empty());
627 }
628
629 #[test]
630 fn chunks_old_empty() {
631 let chunks = compute_diff_chunks("", "hello");
632 assert_eq!(chunks, vec![Chunk::Insert("hello")]);
633 }
634
635 #[test]
636 fn chunks_new_empty() {
637 let chunks = compute_diff_chunks("hello", "");
638 assert_eq!(chunks, vec![Chunk::Delete("hello")]);
639 }
640
641 #[test]
642 fn chunks_identical() {
643 let chunks = compute_diff_chunks("abc", "abc");
644 assert_eq!(chunks.len(), 1);
645 assert!(matches!(chunks[0], Chunk::Equal("abc")));
646 }
647
648 #[test]
649 fn chunks_single_insertion() {
650 let chunks = compute_diff_chunks("ac", "abc");
651 assert_eq!(chunks.len(), 3);
653 assert!(matches!(chunks[0], Chunk::Equal("a")));
654 assert!(matches!(chunks[1], Chunk::Insert("b")));
655 assert!(matches!(chunks[2], Chunk::Equal("c")));
656 }
657
658 #[test]
659 fn chunks_single_deletion() {
660 let chunks = compute_diff_chunks("abc", "ac");
661 assert_eq!(chunks.len(), 3);
662 assert!(matches!(chunks[0], Chunk::Equal("a")));
663 assert!(matches!(chunks[1], Chunk::Delete("b")));
664 assert!(matches!(chunks[2], Chunk::Equal("c")));
665 }
666
667 #[test]
668 fn chunks_replacement() {
669 let chunks = compute_diff_chunks("abc", "axc");
670 assert_eq!(chunks.len(), 4);
672 assert!(matches!(chunks[0], Chunk::Equal("a")));
673 assert!(matches!(chunks[1], Chunk::Delete("b")));
674 assert!(matches!(chunks[2], Chunk::Insert("x")));
675 assert!(matches!(chunks[3], Chunk::Equal("c")));
676 }
677
678 #[test]
679 fn chunks_completely_different() {
680 let chunks = compute_diff_chunks("aaa", "bbb");
681 assert!(!chunks.is_empty());
683 let deletes: usize = chunks.iter().filter(|c| matches!(c, Chunk::Delete(_))).count();
685 let inserts: usize = chunks.iter().filter(|c| matches!(c, Chunk::Insert(_))).count();
686 assert!(deletes > 0 || inserts > 0);
687 }
688
689 #[test]
690 fn chunks_multiline() {
691 let old = "line1\nline2\nline3\n";
692 let new = "line1\nline modified\nline3\n";
693 let chunks = compute_diff_chunks(old, new);
694
695 let has_equal = chunks.iter().any(|c| matches!(c, Chunk::Equal(_)));
697 assert!(has_equal);
698
699 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
701 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
702 assert!(has_delete || has_insert);
703 }
704
705 #[test]
706 fn chunks_unicode() {
707 let old = "hello \u{00e9}l\u{00e8}ve";
708 let new = "hello \u{00e9}l\u{00e8}ve you";
709 let chunks = compute_diff_chunks(old, new);
710
711 let prefix = match &chunks[0] {
713 Chunk::Equal(s) => s,
714 _ => panic!("expected Equal prefix"),
715 };
716 assert!(prefix.starts_with("hello "));
717 }
718
719 #[test]
720 fn chunks_append_only() {
721 let old = "a\nb\n";
722 let new = "a\nb\nc\nd\n";
723 let chunks = compute_diff_chunks(old, new);
724 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
725 assert!(has_insert);
726 }
727
728 #[test]
729 fn chunks_remove_only() {
730 let old = "a\nb\nc\n";
731 let new = "a\n";
732 let chunks = compute_diff_chunks(old, new);
733 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
734 assert!(has_delete);
735 }
736
737 fn identity_formatter(hunks: &[DiffHunk], _opts: &DiffOptions<'_>) -> String {
740 hunks
741 .iter()
742 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
743 .collect::<Vec<_>>()
744 .join("")
745 }
746
747 #[test]
748 fn diff_identical_content() {
749 let result = compute_diff("hello\n", "hello\n", DiffOptions::default(), identity_formatter);
750 assert!(result.is_empty);
751 assert!(result.hunks.is_empty());
752 assert!(result.formatted.is_empty());
753 }
754
755 #[test]
756 fn diff_empty_both() {
757 let result = compute_diff("", "", DiffOptions::default(), identity_formatter);
758 assert!(result.is_empty);
759 assert!(result.hunks.is_empty());
760 }
761
762 #[test]
763 fn diff_old_empty() {
764 let result = compute_diff("", "line1\nline2\n", DiffOptions::default(), identity_formatter);
765 assert!(!result.is_empty);
766 assert!(!result.hunks.is_empty());
767 for hunk in &result.hunks {
769 for line in &hunk.lines {
770 assert_eq!(line.kind, DiffLineKind::Addition);
771 }
772 }
773 }
774
775 #[test]
776 fn diff_new_empty() {
777 let result = compute_diff("line1\nline2\n", "", DiffOptions::default(), identity_formatter);
778 assert!(!result.is_empty);
779 assert!(!result.hunks.is_empty());
780 for hunk in &result.hunks {
781 for line in &hunk.lines {
782 assert_eq!(line.kind, DiffLineKind::Deletion);
783 }
784 }
785 }
786
787 #[test]
788 fn diff_single_line_change() {
789 let old = "aaa\nbbb\nccc\n";
790 let new = "aaa\nxxx\nccc\n";
791 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
792
793 assert!(!result.is_empty);
794 assert_eq!(result.hunks.len(), 1);
795
796 let hunk = &result.hunks[0];
797 let kinds: Vec<DiffLineKind> = hunk.lines.iter().map(|l| l.kind).collect();
799 assert!(kinds.contains(&DiffLineKind::Context));
800 assert!(kinds.contains(&DiffLineKind::Deletion));
801 assert!(kinds.contains(&DiffLineKind::Addition));
802 }
803
804 #[test]
805 fn diff_line_numbers() {
806 let old = "line1\nline2\nline3\n";
807 let new = "line1\nline2 modified\nline3\n";
808 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
809
810 let hunk = &result.hunks[0];
811 for line in &hunk.lines {
813 if line.kind == DiffLineKind::Context {
814 assert!(line.old_line.is_some());
815 assert!(line.new_line.is_some());
816 }
817 }
818 for line in &hunk.lines {
820 if line.kind == DiffLineKind::Deletion {
821 assert!(line.old_line.is_some());
822 assert!(line.new_line.is_none());
823 }
824 }
825 for line in &hunk.lines {
827 if line.kind == DiffLineKind::Addition {
828 assert!(line.old_line.is_none());
829 assert!(line.new_line.is_some());
830 }
831 }
832 }
833
834 #[test]
835 fn diff_context_lines_zero() {
836 let old = "a\nb\nc\nd\ne\n";
837 let new = "a\nb\nX\nd\ne\n";
838 let opts = DiffOptions { context_lines: 0, ..DiffOptions::default() };
839 let result = compute_diff(old, new, opts, identity_formatter);
840
841 assert!(!result.is_empty);
842 let hunk = &result.hunks[0];
844 let context_count = hunk.lines.iter().filter(|l| l.kind == DiffLineKind::Context).count();
846 assert!(context_count <= 2); }
848
849 #[test]
850 fn diff_context_lines_large() {
851 let old = "a\nb\nc\nd\ne\n";
852 let new = "a\nb\nX\nd\ne\n";
853 let opts = DiffOptions { context_lines: 10, ..DiffOptions::default() };
854 let result = compute_diff(old, new, opts, identity_formatter);
855
856 assert!(!result.is_empty);
857 let hunk = &result.hunks[0];
859 assert_eq!(hunk.lines.len(), 6);
860 }
861
862 #[test]
863 fn diff_hunk_metadata() {
864 let old = "aaa\nbbb\nccc\n";
865 let new = "aaa\nxxx\nccc\n";
866 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
867
868 let hunk = &result.hunks[0];
869 assert!(hunk.old_start >= 1);
870 assert!(hunk.new_start >= 1);
871 assert!(hunk.old_lines > 0);
872 assert!(hunk.new_lines > 0);
873 assert!(!hunk.lines.is_empty());
874 }
875
876 #[test]
877 fn diff_multiple_hunks() {
878 let old = "a\nb\nc\nd\ne\nf\ng\nh\n";
880 let new = "a\nINSERTED1\nb\nc\nd\ne\nf\ng\nINSERTED2\nh\n";
881 let opts = DiffOptions { context_lines: 1, ..DiffOptions::default() };
882 let result = compute_diff(old, new, opts, identity_formatter);
883
884 assert!(!result.is_empty);
885 assert!(result.hunks.len() >= 2, "expected at least 2 hunks, got {}", result.hunks.len());
886 }
887
888 #[test]
889 fn diff_formatter_called() {
890 let old = "aaa\n";
891 let new = "bbb\n";
892 let mut called = false;
893 let formatter = |hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
894 called = true;
895 hunks
896 .iter()
897 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
898 .collect::<Vec<_>>()
899 .join("")
900 };
901
902 let result = compute_diff(old, new, DiffOptions::default(), formatter);
903 assert!(called);
904 assert!(!result.formatted.is_empty());
905 }
906
907 #[test]
908 fn diff_formatter_not_called_when_empty() {
909 let mut called = false;
910 let formatter = |_hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
911 called = true;
912 String::new()
913 };
914
915 let result = compute_diff("same\n", "same\n", DiffOptions::default(), formatter);
916 assert!(!called);
917 assert!(result.formatted.is_empty());
918 }
919
920 #[test]
921 fn diff_options_labels() {
922 let old = "aaa\n";
923 let new = "bbb\n";
924 let opts = DiffOptions {
925 old_label: Some("old.txt"),
926 new_label: Some("new.txt"),
927 ..DiffOptions::default()
928 };
929 let result = compute_diff(old, new, opts, identity_formatter);
930 assert!(!result.is_empty);
931 assert_eq!(result.hunks.len(), 1);
933 }
934
935 #[test]
936 fn diff_insertion_only() {
937 let old = "line1\nline3\n";
938 let new = "line1\nline2\nline3\n";
939 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
940
941 assert!(!result.is_empty);
942 let additions: Vec<&DiffLine> = result
943 .hunks
944 .iter()
945 .flat_map(|h| h.lines.iter())
946 .filter(|l| l.kind == DiffLineKind::Addition)
947 .collect();
948 assert_eq!(additions.len(), 1);
949 assert_eq!(additions[0].text, "line2\n");
950 }
951
952 #[test]
953 fn diff_deletion_only() {
954 let old = "line1\nline2\nline3\n";
955 let new = "line1\nline3\n";
956 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
957
958 assert!(!result.is_empty);
959 let deletions: Vec<&DiffLine> = result
960 .hunks
961 .iter()
962 .flat_map(|h| h.lines.iter())
963 .filter(|l| l.kind == DiffLineKind::Deletion)
964 .collect();
965 assert_eq!(deletions.len(), 1);
966 assert_eq!(deletions[0].text, "line2\n");
967 }
968
969 #[test]
972 fn diff_bundle_serializes() {
973 let old = "aaa\nbbb\n";
974 let new = "aaa\nxxx\n";
975 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
976
977 let json = serde_json::to_string(&result).unwrap();
978 assert!(json.contains("hunks"));
979 assert!(json.contains("formatted"));
980 assert!(json.contains("is_empty"));
981 }
982
983 #[test]
984 fn diff_hunk_serializes() {
985 let hunk = DiffHunk {
986 old_start: 1,
987 old_lines: 2,
988 new_start: 1,
989 new_lines: 2,
990 lines: vec![DiffLine {
991 kind: DiffLineKind::Context,
992 old_line: Some(1),
993 new_line: Some(1),
994 text: "hello\n".to_string(),
995 }],
996 };
997 let json = serde_json::to_string(&hunk).unwrap();
998 assert!(json.contains("old_start"));
999 assert!(json.contains("context"));
1000 }
1001
1002 #[test]
1003 fn diff_line_kind_serializes() {
1004 assert_eq!(serde_json::to_string(&DiffLineKind::Context).unwrap(), "\"context\"");
1005 assert_eq!(serde_json::to_string(&DiffLineKind::Addition).unwrap(), "\"addition\"");
1006 assert_eq!(serde_json::to_string(&DiffLineKind::Deletion).unwrap(), "\"deletion\"");
1007 }
1008
1009 #[test]
1012 fn chunks_very_long_identical() {
1013 let text = "x".repeat(10_000);
1014 let chunks = compute_diff_chunks(&text, &text);
1015 assert_eq!(chunks.len(), 1);
1016 assert!(matches!(chunks[0], Chunk::Equal(_)));
1017 }
1018
1019 #[test]
1020 fn chunks_single_char_diff() {
1021 let chunks = compute_diff_chunks("a", "b");
1022 assert!(!chunks.is_empty());
1023 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1024 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1025 assert!(has_delete && has_insert);
1026 }
1027
1028 #[test]
1029 fn diff_no_trailing_newline() {
1030 let old = "line1\nline2";
1031 let new = "line1\nline2\n";
1032 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1033 assert!(!result.is_empty);
1034 }
1035
1036 #[test]
1037 fn diff_only_newlines_differ() {
1038 let old = "a\nb\n";
1039 let new = "a\nb";
1040 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1041 assert!(!result.is_empty);
1042 }
1043
1044 #[test]
1045 fn chunks_prefix_suffix_optimization() {
1046 let old = "AAAA BBBB CCCC";
1049 let new = "AAAA DDDD CCCC";
1050 let chunks = compute_diff_chunks(old, new);
1051
1052 assert!(matches!(&chunks[0], Chunk::Equal(s) if *s == "AAAA "));
1054 assert!(matches!(chunks.last().unwrap(), Chunk::Equal(s) if *s == " CCCC"));
1056 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1058 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1059 assert!(has_delete, "expected Delete chunks in middle");
1060 assert!(has_insert, "expected Insert chunks in middle");
1061 }
1062}