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(old: &[char], new: &[char], v_index: &[usize], d: usize, mut k: i32, max_d: usize) -> Vec<Edit> {
238 let mut edits = Vec::with_capacity(old.len() + new.len());
239 let mut x = old.len();
240 let mut y = new.len();
241 let max_d_i32 = max_d as i32;
242 let row_len = 2 * max_d + 1;
243
244 for cur_d in (0..=d).rev() {
245 if cur_d == 0 {
246 while x > 0 && y > 0 {
247 edits.push(Edit::Equal);
248 x -= 1;
249 y -= 1;
250 }
251 break;
252 }
253
254 let k_idx = (k + max_d_i32) as usize;
255 let prev_row_start = (cur_d - 1) * row_len;
256
257 let cur_d_i32 = cur_d as i32;
258 let prev_k = if k == cur_d_i32.wrapping_neg()
259 || (k != cur_d_i32 && v_index[prev_row_start + k_idx - 1] < v_index[prev_row_start + k_idx + 1])
260 {
261 k + 1
262 } else {
263 k - 1
264 };
265
266 let prev_k_idx = (prev_k + max_d_i32) as usize;
267 let prev_x_val = v_index[prev_row_start + prev_k_idx];
268 let prev_y = (prev_x_val as i32 - prev_k) as usize;
269
270 let (move_x, move_y) = if prev_k == k + 1 {
271 (prev_x_val, prev_y + 1)
272 } else {
273 (prev_x_val + 1, prev_y)
274 };
275
276 (x, y) = backtrack_equal_run(x, y, move_x, move_y, &mut edits);
277
278 if prev_k == k + 1 {
279 edits.push(Edit::Insert);
280 y -= 1;
281 } else {
282 edits.push(Edit::Delete);
283 x -= 1;
284 }
285
286 k = prev_k;
287 }
288
289 edits.reverse();
290 edits
291}
292
293#[derive(Debug, Clone)]
295pub struct DiffOptions<'a> {
296 pub context_lines: usize,
297 pub old_label: Option<&'a str>,
298 pub new_label: Option<&'a str>,
299 pub missing_newline_hint: bool,
300}
301
302impl Default for DiffOptions<'_> {
303 fn default() -> Self {
304 Self {
305 context_lines: 3,
306 old_label: None,
307 new_label: None,
308 missing_newline_hint: true,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize)]
315pub struct DiffBundle {
316 pub hunks: Vec<DiffHunk>,
317 pub formatted: String,
318 pub is_empty: bool,
319}
320
321#[derive(Debug, Clone, Serialize)]
323pub struct DiffHunk {
324 pub old_start: usize,
325 pub old_lines: usize,
326 pub new_start: usize,
327 pub new_lines: usize,
328 pub lines: Vec<DiffLine>,
329}
330
331#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
333#[serde(rename_all = "snake_case")]
334pub enum DiffLineKind {
335 Context,
336 Addition,
337 Deletion,
338}
339
340#[derive(Debug, Clone, Serialize)]
342pub struct DiffLine {
343 pub kind: DiffLineKind,
344 pub old_line: Option<u32>,
345 pub new_line: Option<u32>,
346 pub text: String,
347}
348
349pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
351where
352 F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
353{
354 let old_lines_owned = split_lines_with_terminator(old);
355 let new_lines_owned = split_lines_with_terminator(new);
356
357 let old_refs: Vec<&str> = old_lines_owned.iter().map(|s| s.as_str()).collect();
358 let new_refs: Vec<&str> = new_lines_owned.iter().map(|s| s.as_str()).collect();
359
360 let records = collect_line_records(&old_refs, &new_refs);
361 let has_changes = records
362 .iter()
363 .any(|record| matches!(record.kind, DiffLineKind::Addition | DiffLineKind::Deletion));
364
365 let hunks = if has_changes {
366 build_hunks(&records, options.context_lines)
367 } else {
368 Vec::new()
369 };
370
371 let formatted = if hunks.is_empty() {
372 String::new()
373 } else {
374 formatter(&hunks, &options)
375 };
376
377 DiffBundle { hunks, formatted, is_empty: !has_changes }
378}
379
380fn split_lines_with_terminator(text: &str) -> Vec<String> {
381 if text.is_empty() {
382 return Vec::with_capacity(0);
383 }
384
385 let mut lines: Vec<String> = text.split_inclusive('\n').map(|line| line.to_string()).collect();
386
387 if lines.is_empty() {
388 lines.push(text.to_string());
389 }
390
391 lines
392}
393
394#[inline]
395fn collect_line_records<'a>(old_lines: &'a [&'a str], new_lines: &'a [&'a str]) -> Vec<LineRecord<'a>> {
396 let (old_encoded, new_encoded) = encode_line_sequences(old_lines, new_lines);
397 let mut records = Vec::with_capacity(old_lines.len() + new_lines.len());
398 let mut old_index = 0u32;
399 let mut new_index = 0u32;
400
401 for chunk in compute_diff_chunks(old_encoded.as_str(), new_encoded.as_str()) {
402 match chunk {
403 Chunk::Equal(text) => {
404 for _ in text.chars() {
405 let old_line = old_index + 1;
406 let new_line = new_index + 1;
407 let line = old_lines[old_index as usize];
408 records.push(LineRecord {
409 kind: DiffLineKind::Context,
410 old_line: Some(old_line),
411 new_line: Some(new_line),
412 text: line,
413 anchor_old: old_line,
414 anchor_new: new_line,
415 });
416 old_index += 1;
417 new_index += 1;
418 }
419 }
420 Chunk::Delete(text) => {
421 for _ in text.chars() {
422 let old_line = old_index + 1;
423 let anchor_new = new_index + 1;
424 let line = old_lines[old_index as usize];
425 records.push(LineRecord {
426 kind: DiffLineKind::Deletion,
427 old_line: Some(old_line),
428 new_line: None,
429 text: line,
430 anchor_old: old_line,
431 anchor_new,
432 });
433 old_index += 1;
434 }
435 }
436 Chunk::Insert(text) => {
437 for _ in text.chars() {
438 let new_line = new_index + 1;
439 let anchor_old = old_index + 1;
440 let line = new_lines[new_index as usize];
441 records.push(LineRecord {
442 kind: DiffLineKind::Addition,
443 old_line: None,
444 new_line: Some(new_line),
445 text: line,
446 anchor_old,
447 anchor_new: new_line,
448 });
449 new_index += 1;
450 }
451 }
452 }
453 }
454
455 records
456}
457
458fn encode_line_sequences<'a>(old_lines: &'a [&'a str], new_lines: &'a [&'a str]) -> (String, String) {
459 let mut token_map: HashMap<&'a str, char> = HashMap::new();
460 let mut next_codepoint: u32 = 0;
461
462 let old_encoded = encode_line_list(old_lines, &mut token_map, &mut next_codepoint);
463 let new_encoded = encode_line_list(new_lines, &mut token_map, &mut next_codepoint);
464
465 (old_encoded, new_encoded)
466}
467
468fn encode_line_list<'a>(lines: &'a [&'a str], map: &mut HashMap<&'a str, char>, next_codepoint: &mut u32) -> String {
469 let mut encoded = String::with_capacity(lines.len());
470 for &line in lines {
471 let token = if let Some(&value) = map.get(line) {
472 value
473 } else {
474 let Some(ch) = next_token_char(next_codepoint) else {
475 break;
476 };
477 map.insert(line, ch);
478 ch
479 };
480 encoded.push(token);
481 }
482 encoded
483}
484
485fn next_token_char(counter: &mut u32) -> Option<char> {
486 while *counter <= 0x10FFFF {
487 let candidate = *counter;
488 *counter += 1;
489 if (0xD800..=0xDFFF).contains(&candidate) {
490 continue;
491 }
492 if let Some(ch) = char::from_u32(candidate) {
493 return Some(ch);
494 }
495 }
496 None
497}
498
499#[derive(Debug)]
500struct LineRecord<'a> {
501 kind: DiffLineKind,
502 old_line: Option<u32>,
503 new_line: Option<u32>,
504 text: &'a str,
505 anchor_old: u32,
506 anchor_new: u32,
507}
508
509fn build_hunks(records: &[LineRecord<'_>], context: usize) -> Vec<DiffHunk> {
510 if records.is_empty() {
511 return Vec::new();
512 }
513
514 let ranges = compute_hunk_ranges(records, context);
515 let mut hunks = Vec::with_capacity(ranges.len());
516
517 for (start, end) in ranges {
518 let slice = &records[start..=end];
519
520 let old_start = slice
521 .iter()
522 .filter_map(|r| r.old_line)
523 .min()
524 .or_else(|| slice.iter().map(|r| r.anchor_old).min())
525 .unwrap_or(1) as usize;
526
527 let new_start = slice
528 .iter()
529 .filter_map(|r| r.new_line)
530 .min()
531 .or_else(|| slice.iter().map(|r| r.anchor_new).min())
532 .unwrap_or(1) as usize;
533
534 let old_lines = slice
535 .iter()
536 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Deletion))
537 .count();
538 let new_lines = slice
539 .iter()
540 .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Addition))
541 .count();
542
543 let lines = slice
544 .iter()
545 .map(|record| DiffLine {
546 kind: record.kind,
547 old_line: record.old_line,
548 new_line: record.new_line,
549 text: record.text.to_string(),
550 })
551 .collect();
552
553 hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
554 }
555
556 hunks
557}
558
559fn compute_hunk_ranges(records: &[LineRecord<'_>], context: usize) -> Vec<(usize, usize)> {
560 let mut ranges = Vec::with_capacity(4);
561 let mut current_start: Option<usize> = None;
562 let mut current_end: usize = 0;
563
564 for (idx, record) in records.iter().enumerate() {
565 if record.kind != DiffLineKind::Context {
566 let start = idx.saturating_sub(context);
567 let end = min(idx + context, records.len().saturating_sub(1));
568
569 if let Some(existing_start) = current_start {
570 if idx > current_end {
572 ranges.push((existing_start, current_end));
573 current_start = Some(start);
574 current_end = end;
575 } else {
576 if start < existing_start {
577 current_start = Some(start);
578 }
579 if end > current_end {
580 current_end = end;
581 }
582 }
583 } else {
584 current_start = Some(start);
585 current_end = end;
586 }
587 } else if let Some(start) = current_start
588 && idx > current_end
589 {
590 ranges.push((start, current_end));
591 current_start = None;
592 }
593 }
594
595 if let Some(start) = current_start {
596 ranges.push((start, current_end));
597 }
598
599 ranges
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 #[test]
609 fn chunks_both_empty() {
610 let chunks = compute_diff_chunks("", "");
611 assert!(chunks.is_empty());
612 }
613
614 #[test]
615 fn chunks_old_empty() {
616 let chunks = compute_diff_chunks("", "hello");
617 assert_eq!(chunks, vec![Chunk::Insert("hello")]);
618 }
619
620 #[test]
621 fn chunks_new_empty() {
622 let chunks = compute_diff_chunks("hello", "");
623 assert_eq!(chunks, vec![Chunk::Delete("hello")]);
624 }
625
626 #[test]
627 fn chunks_identical() {
628 let chunks = compute_diff_chunks("abc", "abc");
629 assert_eq!(chunks.len(), 1);
630 assert!(matches!(chunks[0], Chunk::Equal("abc")));
631 }
632
633 #[test]
634 fn chunks_single_insertion() {
635 let chunks = compute_diff_chunks("ac", "abc");
636 assert_eq!(chunks.len(), 3);
638 assert!(matches!(chunks[0], Chunk::Equal("a")));
639 assert!(matches!(chunks[1], Chunk::Insert("b")));
640 assert!(matches!(chunks[2], Chunk::Equal("c")));
641 }
642
643 #[test]
644 fn chunks_single_deletion() {
645 let chunks = compute_diff_chunks("abc", "ac");
646 assert_eq!(chunks.len(), 3);
647 assert!(matches!(chunks[0], Chunk::Equal("a")));
648 assert!(matches!(chunks[1], Chunk::Delete("b")));
649 assert!(matches!(chunks[2], Chunk::Equal("c")));
650 }
651
652 #[test]
653 fn chunks_replacement() {
654 let chunks = compute_diff_chunks("abc", "axc");
655 assert_eq!(chunks.len(), 4);
657 assert!(matches!(chunks[0], Chunk::Equal("a")));
658 assert!(matches!(chunks[1], Chunk::Delete("b")));
659 assert!(matches!(chunks[2], Chunk::Insert("x")));
660 assert!(matches!(chunks[3], Chunk::Equal("c")));
661 }
662
663 #[test]
664 fn chunks_completely_different() {
665 let chunks = compute_diff_chunks("aaa", "bbb");
666 assert!(!chunks.is_empty());
668 let deletes: usize = chunks.iter().filter(|c| matches!(c, Chunk::Delete(_))).count();
670 let inserts: usize = chunks.iter().filter(|c| matches!(c, Chunk::Insert(_))).count();
671 assert!(deletes > 0 || inserts > 0);
672 }
673
674 #[test]
675 fn chunks_multiline() {
676 let old = "line1\nline2\nline3\n";
677 let new = "line1\nline modified\nline3\n";
678 let chunks = compute_diff_chunks(old, new);
679
680 let has_equal = chunks.iter().any(|c| matches!(c, Chunk::Equal(_)));
682 assert!(has_equal);
683
684 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
686 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
687 assert!(has_delete || has_insert);
688 }
689
690 #[test]
691 fn chunks_unicode() {
692 let old = "hello \u{00e9}l\u{00e8}ve";
693 let new = "hello \u{00e9}l\u{00e8}ve you";
694 let chunks = compute_diff_chunks(old, new);
695
696 let prefix = match &chunks[0] {
698 Chunk::Equal(s) => s,
699 _ => panic!("expected Equal prefix"),
700 };
701 assert!(prefix.starts_with("hello "));
702 }
703
704 #[test]
705 fn chunks_append_only() {
706 let old = "a\nb\n";
707 let new = "a\nb\nc\nd\n";
708 let chunks = compute_diff_chunks(old, new);
709 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
710 assert!(has_insert);
711 }
712
713 #[test]
714 fn chunks_remove_only() {
715 let old = "a\nb\nc\n";
716 let new = "a\n";
717 let chunks = compute_diff_chunks(old, new);
718 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
719 assert!(has_delete);
720 }
721
722 fn identity_formatter(hunks: &[DiffHunk], _opts: &DiffOptions<'_>) -> String {
725 hunks
726 .iter()
727 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
728 .collect::<Vec<_>>()
729 .join("")
730 }
731
732 #[test]
733 fn diff_identical_content() {
734 let result = compute_diff("hello\n", "hello\n", DiffOptions::default(), identity_formatter);
735 assert!(result.is_empty);
736 assert!(result.hunks.is_empty());
737 assert!(result.formatted.is_empty());
738 }
739
740 #[test]
741 fn diff_empty_both() {
742 let result = compute_diff("", "", DiffOptions::default(), identity_formatter);
743 assert!(result.is_empty);
744 assert!(result.hunks.is_empty());
745 }
746
747 #[test]
748 fn diff_old_empty() {
749 let result = compute_diff("", "line1\nline2\n", DiffOptions::default(), identity_formatter);
750 assert!(!result.is_empty);
751 assert!(!result.hunks.is_empty());
752 for hunk in &result.hunks {
754 for line in &hunk.lines {
755 assert_eq!(line.kind, DiffLineKind::Addition);
756 }
757 }
758 }
759
760 #[test]
761 fn diff_new_empty() {
762 let result = compute_diff("line1\nline2\n", "", DiffOptions::default(), identity_formatter);
763 assert!(!result.is_empty);
764 assert!(!result.hunks.is_empty());
765 for hunk in &result.hunks {
766 for line in &hunk.lines {
767 assert_eq!(line.kind, DiffLineKind::Deletion);
768 }
769 }
770 }
771
772 #[test]
773 fn diff_single_line_change() {
774 let old = "aaa\nbbb\nccc\n";
775 let new = "aaa\nxxx\nccc\n";
776 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
777
778 assert!(!result.is_empty);
779 assert_eq!(result.hunks.len(), 1);
780
781 let hunk = &result.hunks[0];
782 let kinds: Vec<DiffLineKind> = hunk.lines.iter().map(|l| l.kind).collect();
784 assert!(kinds.contains(&DiffLineKind::Context));
785 assert!(kinds.contains(&DiffLineKind::Deletion));
786 assert!(kinds.contains(&DiffLineKind::Addition));
787 }
788
789 #[test]
790 fn diff_line_numbers() {
791 let old = "line1\nline2\nline3\n";
792 let new = "line1\nline2 modified\nline3\n";
793 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
794
795 let hunk = &result.hunks[0];
796 for line in &hunk.lines {
798 if line.kind == DiffLineKind::Context {
799 assert!(line.old_line.is_some());
800 assert!(line.new_line.is_some());
801 }
802 }
803 for line in &hunk.lines {
805 if line.kind == DiffLineKind::Deletion {
806 assert!(line.old_line.is_some());
807 assert!(line.new_line.is_none());
808 }
809 }
810 for line in &hunk.lines {
812 if line.kind == DiffLineKind::Addition {
813 assert!(line.old_line.is_none());
814 assert!(line.new_line.is_some());
815 }
816 }
817 }
818
819 #[test]
820 fn diff_context_lines_zero() {
821 let old = "a\nb\nc\nd\ne\n";
822 let new = "a\nb\nX\nd\ne\n";
823 let opts = DiffOptions { context_lines: 0, ..DiffOptions::default() };
824 let result = compute_diff(old, new, opts, identity_formatter);
825
826 assert!(!result.is_empty);
827 let hunk = &result.hunks[0];
829 let context_count = hunk.lines.iter().filter(|l| l.kind == DiffLineKind::Context).count();
831 assert!(context_count <= 2); }
833
834 #[test]
835 fn diff_context_lines_large() {
836 let old = "a\nb\nc\nd\ne\n";
837 let new = "a\nb\nX\nd\ne\n";
838 let opts = DiffOptions { context_lines: 10, ..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 assert_eq!(hunk.lines.len(), 6);
845 }
846
847 #[test]
848 fn diff_hunk_metadata() {
849 let old = "aaa\nbbb\nccc\n";
850 let new = "aaa\nxxx\nccc\n";
851 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
852
853 let hunk = &result.hunks[0];
854 assert!(hunk.old_start >= 1);
855 assert!(hunk.new_start >= 1);
856 assert!(hunk.old_lines > 0);
857 assert!(hunk.new_lines > 0);
858 assert!(!hunk.lines.is_empty());
859 }
860
861 #[test]
862 fn diff_multiple_hunks() {
863 let old = "a\nb\nc\nd\ne\nf\ng\nh\n";
865 let new = "a\nINSERTED1\nb\nc\nd\ne\nf\ng\nINSERTED2\nh\n";
866 let opts = DiffOptions { context_lines: 1, ..DiffOptions::default() };
867 let result = compute_diff(old, new, opts, identity_formatter);
868
869 assert!(!result.is_empty);
870 assert!(result.hunks.len() >= 2, "expected at least 2 hunks, got {}", result.hunks.len());
871 }
872
873 #[test]
874 fn diff_formatter_called() {
875 let old = "aaa\n";
876 let new = "bbb\n";
877 let mut called = false;
878 let formatter = |hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
879 called = true;
880 hunks
881 .iter()
882 .flat_map(|h| h.lines.iter().map(|l| l.text.clone()))
883 .collect::<Vec<_>>()
884 .join("")
885 };
886
887 let result = compute_diff(old, new, DiffOptions::default(), formatter);
888 assert!(called);
889 assert!(!result.formatted.is_empty());
890 }
891
892 #[test]
893 fn diff_formatter_not_called_when_empty() {
894 let mut called = false;
895 let formatter = |_hunks: &[DiffHunk], _opts: &DiffOptions<'_>| -> String {
896 called = true;
897 String::new()
898 };
899
900 let result = compute_diff("same\n", "same\n", DiffOptions::default(), formatter);
901 assert!(!called);
902 assert!(result.formatted.is_empty());
903 }
904
905 #[test]
906 fn diff_options_labels() {
907 let old = "aaa\n";
908 let new = "bbb\n";
909 let opts = DiffOptions {
910 old_label: Some("old.txt"),
911 new_label: Some("new.txt"),
912 ..DiffOptions::default()
913 };
914 let result = compute_diff(old, new, opts, identity_formatter);
915 assert!(!result.is_empty);
916 assert_eq!(result.hunks.len(), 1);
918 }
919
920 #[test]
921 fn diff_insertion_only() {
922 let old = "line1\nline3\n";
923 let new = "line1\nline2\nline3\n";
924 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
925
926 assert!(!result.is_empty);
927 let additions: Vec<&DiffLine> = result
928 .hunks
929 .iter()
930 .flat_map(|h| h.lines.iter())
931 .filter(|l| l.kind == DiffLineKind::Addition)
932 .collect();
933 assert_eq!(additions.len(), 1);
934 assert_eq!(additions[0].text, "line2\n");
935 }
936
937 #[test]
938 fn diff_deletion_only() {
939 let old = "line1\nline2\nline3\n";
940 let new = "line1\nline3\n";
941 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
942
943 assert!(!result.is_empty);
944 let deletions: Vec<&DiffLine> = result
945 .hunks
946 .iter()
947 .flat_map(|h| h.lines.iter())
948 .filter(|l| l.kind == DiffLineKind::Deletion)
949 .collect();
950 assert_eq!(deletions.len(), 1);
951 assert_eq!(deletions[0].text, "line2\n");
952 }
953
954 #[test]
957 fn diff_bundle_serializes() {
958 let old = "aaa\nbbb\n";
959 let new = "aaa\nxxx\n";
960 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
961
962 let json = serde_json::to_string(&result).unwrap();
963 assert!(json.contains("hunks"));
964 assert!(json.contains("formatted"));
965 assert!(json.contains("is_empty"));
966 }
967
968 #[test]
969 fn diff_hunk_serializes() {
970 let hunk = DiffHunk {
971 old_start: 1,
972 old_lines: 2,
973 new_start: 1,
974 new_lines: 2,
975 lines: vec![DiffLine {
976 kind: DiffLineKind::Context,
977 old_line: Some(1),
978 new_line: Some(1),
979 text: "hello\n".to_string(),
980 }],
981 };
982 let json = serde_json::to_string(&hunk).unwrap();
983 assert!(json.contains("old_start"));
984 assert!(json.contains("context"));
985 }
986
987 #[test]
988 fn diff_line_kind_serializes() {
989 assert_eq!(serde_json::to_string(&DiffLineKind::Context).unwrap(), "\"context\"");
990 assert_eq!(serde_json::to_string(&DiffLineKind::Addition).unwrap(), "\"addition\"");
991 assert_eq!(serde_json::to_string(&DiffLineKind::Deletion).unwrap(), "\"deletion\"");
992 }
993
994 #[test]
997 fn chunks_very_long_identical() {
998 let text = "x".repeat(10_000);
999 let chunks = compute_diff_chunks(&text, &text);
1000 assert_eq!(chunks.len(), 1);
1001 assert!(matches!(chunks[0], Chunk::Equal(_)));
1002 }
1003
1004 #[test]
1005 fn chunks_single_char_diff() {
1006 let chunks = compute_diff_chunks("a", "b");
1007 assert!(!chunks.is_empty());
1008 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1009 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1010 assert!(has_delete && has_insert);
1011 }
1012
1013 #[test]
1014 fn diff_no_trailing_newline() {
1015 let old = "line1\nline2";
1016 let new = "line1\nline2\n";
1017 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1018 assert!(!result.is_empty);
1019 }
1020
1021 #[test]
1022 fn diff_only_newlines_differ() {
1023 let old = "a\nb\n";
1024 let new = "a\nb";
1025 let result = compute_diff(old, new, DiffOptions::default(), identity_formatter);
1026 assert!(!result.is_empty);
1027 }
1028
1029 #[test]
1030 fn chunks_prefix_suffix_optimization() {
1031 let old = "AAAA BBBB CCCC";
1034 let new = "AAAA DDDD CCCC";
1035 let chunks = compute_diff_chunks(old, new);
1036
1037 assert!(matches!(&chunks[0], Chunk::Equal(s) if *s == "AAAA "));
1039 assert!(matches!(chunks.last().unwrap(), Chunk::Equal(s) if *s == " CCCC"));
1041 let has_delete = chunks.iter().any(|c| matches!(c, Chunk::Delete(_)));
1043 let has_insert = chunks.iter().any(|c| matches!(c, Chunk::Insert(_)));
1044 assert!(has_delete, "expected Delete chunks in middle");
1045 assert!(has_insert, "expected Insert chunks in middle");
1046 }
1047}