Skip to main content

vtcode_commons/
diff.rs

1//! Diff utilities for generating structured diffs.
2
3use hashbrown::HashMap;
4use serde::Serialize;
5use std::cmp::min;
6
7/// Represents a chunk of text in a diff (Equal, Delete, or Insert).
8#[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/// Compute an optimal diff between two strings using Myers algorithm.
16#[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    // Strip common prefix first (optimisation).
29    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    // Strip common suffix on the remaining text.
37    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    // Add common prefix
57    if prefix_byte_len > 0 {
58        result.push(Chunk::Equal(&old[..prefix_byte_len]));
59    }
60
61    // Compute optimal diff for the middle section
62    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        // Track the start of a consecutive Equal run so we can emit a single
72        // Chunk::Equal for the whole run (instead of one per character).
73        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                    // Flush any accumulated equal run before emitting a Delete
86                    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                    // Flush any accumulated equal run before emitting an Insert.
105                    // old_pos may equal old_byte_starts.len() when the equal run
106                    // reaches the end of old_middle, so use old_middle.len() as fallback.
107                    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        // Flush any trailing equal run
131        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    // Add common suffix
141    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/// Advance along matching characters. Extracted from `myers_diff` so the
156/// compiler sees a tight leaf loop with no surrounding state, enabling better
157/// register allocation and (in some cases) auto-vectorization heuristics.
158#[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/// Erase the trailing equal run during backtracking. Same rationale as
168/// `advance_matching` — a focused leaf function that the compiler can
169/// optimise in isolation.
170/// Returns the final `(x, y)` position after removing equal edits.
171#[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/// Options for diff generation.
294#[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/// A diff rendered with both structured hunks and formatted text.
314#[derive(Debug, Clone, Serialize)]
315pub struct DiffBundle {
316    pub hunks: Vec<DiffHunk>,
317    pub formatted: String,
318    pub is_empty: bool,
319}
320
321/// A diff hunk with metadata for old/new ranges.
322#[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/// A single diff line annotated with metadata and type.
332#[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/// Metadata for a single line inside a diff hunk.
341#[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
349/// Compute a structured diff bundle.
350pub 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                // Close the previous range if this change is beyond its context window
571                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    // ── compute_diff_chunks ──────────────────────────────────────────
607
608    #[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        // Common prefix "a", insert "b", common suffix "c"
637        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        // Equal("a"), Delete("b"), Insert("x"), Equal("c")
656        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        // No common prefix or suffix
667        assert!(!chunks.is_empty());
668        // All old chars deleted, all new chars inserted
669        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        // Should have at least some Equal chunks for the unchanged lines
681        let has_equal = chunks.iter().any(|c| matches!(c, Chunk::Equal(_)));
682        assert!(has_equal);
683
684        // Should have a delete and insert for the changed line
685        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        // Common prefix should include unicode chars
697        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    // ── compute_diff ─────────────────────────────────────────────────
723
724    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        // All lines should be additions
753        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        // Should have context lines for aaa and ccc, plus the change
783        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        // Context lines should have both old_line and new_line
797        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        // Deletion should have old_line but no new_line
804        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        // Addition should have new_line but no old_line
811        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        // With 0 context, only the changed line and its neighbors should appear
828        let hunk = &result.hunks[0];
829        // Should be minimal: just the deletion and addition
830        let context_count = hunk.lines.iter().filter(|l| l.kind == DiffLineKind::Context).count();
831        assert!(context_count <= 2); // At most one context line on each side
832    }
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        // With 10 context lines and only 6 total lines (trailing newline creates 6th), all lines appear
843        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        // Insert in first half and insert in second half with small context => two hunks
864        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        // Labels are passed to formatter but don't affect hunks
917        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    // ── DiffBundle serialization ─────────────────────────────────────
955
956    #[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    // ── Edge cases ───────────────────────────────────────────────────
995
996    #[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        // Verify that common prefix and suffix are preserved as Equal chunks.
1032        // Myers works character-by-character, so the middle diff is char-level.
1033        let old = "AAAA BBBB CCCC";
1034        let new = "AAAA DDDD CCCC";
1035        let chunks = compute_diff_chunks(old, new);
1036
1037        // First chunk should be Equal prefix "AAAA "
1038        assert!(matches!(&chunks[0], Chunk::Equal(s) if *s == "AAAA "));
1039        // Last chunk should be Equal suffix " CCCC"
1040        assert!(matches!(chunks.last().unwrap(), Chunk::Equal(s) if *s == " CCCC"));
1041        // Middle should contain deletes and inserts (character-level)
1042        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}