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