Skip to main content

vtcode_commons/
diff.rs

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