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