Skip to main content

vtcode_commons/
diff_preview.rs

1#![expect(
2    clippy::string_slice,
3    clippy::cast_possible_truncation,
4    clippy::indexing_slicing,
5    reason = "Word-level LCS walks atom indices by design; offsets are bounded to the source line."
6)]
7
8//! Shared helpers for rendering diff previews.
9//!
10//! Layout: `{sign}{line_no} │ content` (single gutter) with two-level
11//! backgrounds — full-width add/del tint plus stronger word-level chips on
12//! tokens that differ from the paired opposite line.
13
14use crate::diff::{DiffHunk, DiffLineKind};
15use crate::diff_paths::{
16    format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line, parse_hunk_starts,
17};
18
19/// Intra-line highlight: list of `(start, end)` byte ranges in a line body.
20pub type WordChangedRanges = Vec<(usize, usize)>;
21
22/// Cap atoms so the LCS DP stays O(n·m) with a hard memory bound on huge lines.
23const MAX_WORD_ATOMS: usize = 512;
24
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub struct DiffChangeCounts {
27    pub additions: usize,
28    pub deletions: usize,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum DiffDisplayKind {
33    Metadata,
34    HunkHeader,
35    Context,
36    Addition,
37    Deletion,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct DiffDisplayLine {
42    pub kind: DiffDisplayKind,
43    /// Line number in the old file (context/deletion), when known.
44    pub old_line: Option<u32>,
45    /// Line number in the new file (context/addition), when known.
46    pub new_line: Option<u32>,
47    pub text: String,
48    /// Byte ranges inside `text` that differ from the paired opposite line
49    /// (word-level / intra-line highlight). Empty when no pair or no overlap.
50    pub changed: WordChangedRanges,
51}
52
53impl DiffDisplayLine {
54    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
55        Self {
56            kind,
57            old_line,
58            new_line,
59            text,
60            changed: Vec::new(),
61        }
62    }
63
64    /// Whether this line carries diff content, without re-parsing its text.
65    pub fn is_diff(&self) -> bool {
66        self.kind.is_diff()
67    }
68
69    /// Single-gutter form: `sign + number + │ + content`.
70    ///
71    /// Deletions show the old number, additions the new, context the new
72    /// (falling back to old). The `│` keeps markdown bullets (`- foo`)
73    /// distinct from the diff marker.
74    pub fn numbered_text(&self, line_number_width: usize) -> String {
75        let w = line_number_width;
76        match self.kind {
77            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
78            DiffDisplayKind::Deletion => {
79                format!("-{:>w$} │ {}", self.old_line.unwrap_or_default(), self.text)
80            }
81            DiffDisplayKind::Addition => {
82                format!("+{:>w$} │ {}", self.new_line.unwrap_or_default(), self.text)
83            }
84            DiffDisplayKind::Context => {
85                let no = self.new_line.or(self.old_line).unwrap_or_default();
86                format!(" {:>w$} │ {}", no, self.text)
87            }
88        }
89    }
90}
91
92impl DiffDisplayKind {
93    /// Whether this kind carries diff body content (context, addition, or
94    /// deletion) rather than metadata or a hunk header.
95    pub fn is_diff(self) -> bool {
96        matches!(self, Self::Context | Self::Addition | Self::Deletion)
97    }
98}
99
100impl DiffChangeCounts {
101    pub fn total(self) -> usize {
102        self.additions + self.deletions
103    }
104}
105
106pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
107    let mut counts = DiffChangeCounts::default();
108
109    for hunk in hunks {
110        for line in &hunk.lines {
111            match line.kind {
112                DiffLineKind::Addition => counts.additions += 1,
113                DiffLineKind::Deletion => counts.deletions += 1,
114                DiffLineKind::Context => {}
115            }
116        }
117    }
118
119    counts
120}
121
122pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
123    // Each hunk contributes 1 header + its lines; pre-size to avoid reallocations
124    // on large diffs (the count is exact, so no over-allocation).
125    let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
126    let mut lines = Vec::with_capacity(total);
127
128    for hunk in hunks {
129        lines.push(DiffDisplayLine::body(
130            DiffDisplayKind::HunkHeader,
131            None,
132            None,
133            format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
134        ));
135
136        for line in &hunk.lines {
137            lines.push(display_line_from_diff_line(line));
138        }
139    }
140
141    annotate_word_level_diffs(&mut lines);
142    lines
143}
144
145/// One rendered row in a side-by-side diff view.
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct SideBySideRow {
148    /// Full-width band (hunk header / metadata) stored on `left`; otherwise
149    /// the old-file pane. `None` means an empty left cell.
150    pub left: Option<DiffDisplayLine>,
151    /// New-file pane. `None` means an empty right cell.
152    pub right: Option<DiffDisplayLine>,
153}
154
155impl SideBySideRow {
156    /// True when this row is a full-width band rather than a split pair.
157    pub fn is_full_width(&self) -> bool {
158        self.left
159            .as_ref()
160            .is_some_and(|l| matches!(l.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
161            && self.right.is_none()
162    }
163}
164
165/// Pair display lines into side-by-side rows.
166///
167/// Context lines appear on both sides. Consecutive deletion/addition runs are
168/// zipped index-wise so old and new lines sit on the same visual row. Hunk
169/// headers and metadata span the full width (stored on `left`).
170pub fn side_by_side_rows(lines: &[DiffDisplayLine]) -> Vec<SideBySideRow> {
171    let mut rows = Vec::with_capacity(lines.len());
172    let mut i = 0usize;
173
174    while i < lines.len() {
175        match lines[i].kind {
176            DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata => {
177                rows.push(SideBySideRow { left: Some(lines[i].clone()), right: None });
178                i += 1;
179            }
180            DiffDisplayKind::Context => {
181                rows.push(SideBySideRow {
182                    left: Some(lines[i].clone()),
183                    right: Some(lines[i].clone()),
184                });
185                i += 1;
186            }
187            DiffDisplayKind::Deletion => {
188                let del_start = i;
189                while i < lines.len() && lines[i].kind == DiffDisplayKind::Deletion {
190                    i += 1;
191                }
192                let dels = &lines[del_start..i];
193                let add_start = i;
194                while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
195                    i += 1;
196                }
197                let adds = &lines[add_start..i];
198                let pairs = dels.len().max(adds.len());
199                for pair in 0..pairs {
200                    rows.push(SideBySideRow {
201                        left: dels.get(pair).cloned(),
202                        right: adds.get(pair).cloned(),
203                    });
204                }
205            }
206            DiffDisplayKind::Addition => {
207                let add_start = i;
208                while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
209                    i += 1;
210                }
211                for line in &lines[add_start..i] {
212                    rows.push(SideBySideRow { left: None, right: Some(line.clone()) });
213                }
214            }
215        }
216    }
217
218    rows
219}
220
221pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
222    // Upper-bound the capacity to the line count — the double scan is cheap
223    // (L1-bound byte search) and avoids 10+ reallocations on large diffs.
224    let mut lines = Vec::with_capacity(diff_content.lines().count());
225    let mut old_line_no = 0u32;
226    let mut new_line_no = 0u32;
227    let mut in_hunk = false;
228
229    for line in diff_content.lines() {
230        if let Some((old_start, new_start)) = parse_hunk_starts(line) {
231            old_line_no = old_start as u32;
232            new_line_no = new_start as u32;
233            in_hunk = true;
234            lines.push(DiffDisplayLine::body(
235                DiffDisplayKind::HunkHeader,
236                None,
237                None,
238                format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
239            ));
240            continue;
241        }
242
243        if !in_hunk {
244            lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
245            continue;
246        }
247
248        if is_diff_addition_line(line) {
249            lines.push(DiffDisplayLine::body(
250                DiffDisplayKind::Addition,
251                None,
252                Some(new_line_no),
253                line[1..].to_string(),
254            ));
255            new_line_no = new_line_no.saturating_add(1);
256            continue;
257        }
258
259        if is_diff_deletion_line(line) {
260            lines.push(DiffDisplayLine::body(
261                DiffDisplayKind::Deletion,
262                Some(old_line_no),
263                None,
264                line[1..].to_string(),
265            ));
266            old_line_no = old_line_no.saturating_add(1);
267            continue;
268        }
269
270        if let Some(context_line) = line.strip_prefix(' ') {
271            lines.push(DiffDisplayLine::body(
272                DiffDisplayKind::Context,
273                Some(old_line_no),
274                Some(new_line_no),
275                context_line.to_string(),
276            ));
277            old_line_no = old_line_no.saturating_add(1);
278            new_line_no = new_line_no.saturating_add(1);
279            continue;
280        }
281
282        if let Some(omitted) = parse_omitted_line_count(line) {
283            old_line_no = old_line_no.saturating_add(omitted);
284            new_line_no = new_line_no.saturating_add(omitted);
285            lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
286            continue;
287        }
288
289        lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
290    }
291
292    annotate_word_level_diffs(&mut lines);
293    lines
294}
295
296pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
297    let max_digits = lines
298        .iter()
299        .flat_map(|line| [line.old_line, line.new_line])
300        .flatten()
301        .map(digit_count)
302        .max()
303        .unwrap_or(4);
304    max_digits.clamp(5, 6)
305}
306
307fn digit_count(mut value: u32) -> usize {
308    let mut digits = 1;
309    while value >= 10 {
310        value /= 10;
311        digits += 1;
312    }
313    digits
314}
315
316pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
317    let display_lines = display_lines_from_unified_diff(diff_content);
318    let width = diff_display_line_number_width(&display_lines);
319    display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
320}
321
322/// Parse the number of omitted lines from a condensation marker such as
323/// `"... 12 lines omitted ..."`.
324fn parse_omitted_line_count(line: &str) -> Option<u32> {
325    let trimmed = line.trim();
326    let after = trimmed.strip_prefix("...")?;
327    let after = after.trim_start();
328    let digits_end = after.find(|ch: char| !ch.is_ascii_digit())?;
329    if digits_end == 0 {
330        return None;
331    }
332    after[..digits_end].parse().ok()
333}
334
335fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
336    let text = line.text.trim_end_matches('\n').to_string();
337    match line.kind {
338        DiffLineKind::Context => DiffDisplayLine::body(DiffDisplayKind::Context, line.old_line, line.new_line, text),
339        DiffLineKind::Addition => DiffDisplayLine::body(DiffDisplayKind::Addition, line.old_line, line.new_line, text),
340        DiffLineKind::Deletion => DiffDisplayLine::body(DiffDisplayKind::Deletion, line.old_line, line.new_line, text),
341    }
342}
343
344/// Annotate word-level (intra-line) changed ranges on consecutive `-`/`+` pairs.
345///
346/// Groups consecutive Deletion lines followed by consecutive Addition lines,
347/// pairs them index-wise, and stores byte ranges of tokens that differ. This
348/// powers the two-level background: full-width line tint + stronger word chips.
349pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
350    let mut i = 0;
351    while i < lines.len() {
352        if lines[i].kind != DiffDisplayKind::Deletion {
353            i += 1;
354            continue;
355        }
356        let del_start = i;
357        while i < lines.len() && lines[i].kind == DiffDisplayKind::Deletion {
358            i += 1;
359        }
360        let del_end = i;
361        let add_start = i;
362        while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
363            i += 1;
364        }
365        let add_end = i;
366        let (before, rest) = lines.split_at_mut(add_start);
367        let dels = &mut before[del_start..del_end];
368        let adds = &mut rest[..(add_end - add_start)];
369        pair_word_level_ranges(dels, adds);
370    }
371}
372
373fn pair_word_level_ranges(dels: &mut [DiffDisplayLine], adds: &mut [DiffDisplayLine]) {
374    let pairs = dels.len().min(adds.len());
375    let mut computed = Vec::with_capacity(pairs);
376    for idx in 0..pairs {
377        computed.push(word_level_changed_ranges(&dels[idx].text, &adds[idx].text));
378    }
379    for (idx, (old_ranges, new_ranges)) in computed.into_iter().enumerate() {
380        dels[idx].changed = old_ranges;
381        adds[idx].changed = new_ranges;
382    }
383}
384
385/// Minimum shared-token ratio before word chips are useful.
386///
387/// Below this, the pair is effectively a replace of whole lines — chips would
388/// paint nearly every token and drown the clean full-width line tint.
389const MIN_WORD_SIMILARITY: f64 = 0.35;
390
391/// Split into word-ish atoms: identifier runs, whitespace runs, single other chars.
392fn tokenize_atoms(text: &str) -> Vec<(usize, usize)> {
393    let bytes = text.as_bytes();
394    let mut spans = Vec::with_capacity(text.len() / 2 + 1);
395    let mut i = 0;
396    while i < bytes.len() {
397        let b = bytes[i];
398        let start = i;
399        if b.is_ascii_whitespace() {
400            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
401                i += 1;
402            }
403        } else if b.is_ascii_alphanumeric() || b == b'_' {
404            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
405                i += 1;
406            }
407        } else {
408            let ch_len = text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
409            i += ch_len;
410        }
411        if i > start {
412            spans.push((start, i));
413        }
414    }
415    spans
416}
417
418/// Compute byte ranges in `old`/`new` that are not part of a common token subsequence.
419///
420/// Returns empty ranges when the lines are too dissimilar (or one side is
421/// blank) so pure inserts/deletes keep a clean full-width tint without chips.
422pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
423    let old_atoms = tokenize_atoms(old);
424    let new_atoms = tokenize_atoms(new);
425    if old_atoms.is_empty() || new_atoms.is_empty() {
426        return (WordChangedRanges::new(), WordChangedRanges::new());
427    }
428    // Pathological long lines: skip chips rather than run a huge LCS DP.
429    if old_atoms.len() > MAX_WORD_ATOMS || new_atoms.len() > MAX_WORD_ATOMS {
430        return (WordChangedRanges::new(), WordChangedRanges::new());
431    }
432
433    let n = old_atoms.len();
434    let m = new_atoms.len();
435    let mut dp = vec![vec![0usize; m + 1]; n + 1];
436    for i in (0..n).rev() {
437        for j in (0..m).rev() {
438            let old_tok = &old[old_atoms[i].0..old_atoms[i].1];
439            let new_tok = &new[new_atoms[j].0..new_atoms[j].1];
440            dp[i][j] = if old_tok == new_tok {
441                dp[i + 1][j + 1] + 1
442            } else {
443                dp[i + 1][j].max(dp[i][j + 1])
444            };
445        }
446    }
447
448    let lcs = dp[0][0];
449    let max_len = n.max(m);
450    let similarity = if max_len == 0 { 1.0 } else { lcs as f64 / max_len as f64 };
451    if similarity < MIN_WORD_SIMILARITY {
452        return (WordChangedRanges::new(), WordChangedRanges::new());
453    }
454
455    let mut old_changed = vec![false; n];
456    let mut new_changed = vec![false; m];
457    let (mut i, mut j) = (0usize, 0usize);
458    while i < n && j < m {
459        let old_tok = &old[old_atoms[i].0..old_atoms[i].1];
460        let new_tok = &new[new_atoms[j].0..new_atoms[j].1];
461        if old_tok == new_tok {
462            i += 1;
463            j += 1;
464        } else if dp[i + 1][j] >= dp[i][j + 1] {
465            old_changed[i] = true;
466            i += 1;
467        } else {
468            new_changed[j] = true;
469            j += 1;
470        }
471    }
472    while i < n {
473        old_changed[i] = true;
474        i += 1;
475    }
476    while j < m {
477        new_changed[j] = true;
478        j += 1;
479    }
480
481    let old_changed_count = old_changed.iter().filter(|&&f| f).count();
482    let new_changed_count = new_changed.iter().filter(|&&f| f).count();
483    if mostly_changed(old_changed_count, n) || mostly_changed(new_changed_count, m) {
484        return (WordChangedRanges::new(), WordChangedRanges::new());
485    }
486
487    (
488        collapse_atom_flags(&old_atoms, &old_changed, old),
489        collapse_atom_flags(&new_atoms, &new_changed, new),
490    )
491}
492
493/// True when more than half the atoms on a side are marked changed.
494fn mostly_changed(changed_atoms: usize, atom_count: usize) -> bool {
495    atom_count > 0 && changed_atoms * 2 > atom_count
496}
497
498fn collapse_atom_flags(atoms: &[(usize, usize)], flags: &[bool], text: &str) -> Vec<(usize, usize)> {
499    let mut ranges: Vec<(usize, usize)> = Vec::new();
500    for (idx, (start, end)) in atoms.iter().enumerate() {
501        if !flags[idx] {
502            continue;
503        }
504        match ranges.last_mut() {
505            Some(last) if last.1 == *start => last.1 = *end,
506            _ => ranges.push((*start, *end)),
507        }
508    }
509    ranges.retain(|(start, end)| text[*start..*end].chars().any(|c| !c.is_ascii_whitespace()));
510    ranges
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::diff::{DiffLine, DiffLineKind};
517
518    #[test]
519    fn counts_diff_changes_from_hunks() {
520        let hunks = vec![DiffHunk {
521            old_start: 1,
522            old_lines: 2,
523            new_start: 1,
524            new_lines: 2,
525            lines: vec![
526                DiffLine {
527                    kind: DiffLineKind::Context,
528                    old_line: Some(1),
529                    new_line: Some(1),
530                    text: "same\n".to_string(),
531                },
532                DiffLine {
533                    kind: DiffLineKind::Deletion,
534                    old_line: Some(2),
535                    new_line: None,
536                    text: "old\n".to_string(),
537                },
538                DiffLine {
539                    kind: DiffLineKind::Addition,
540                    old_line: None,
541                    new_line: Some(2),
542                    text: "new\n".to_string(),
543                },
544            ],
545        }];
546
547        let counts = count_diff_changes(&hunks);
548        assert_eq!(counts.additions, 1);
549        assert_eq!(counts.deletions, 1);
550        assert_eq!(counts.total(), 2);
551    }
552
553    #[test]
554    fn is_diff_discriminates_content_lines() {
555        assert!(DiffDisplayKind::Context.is_diff());
556        assert!(DiffDisplayKind::Addition.is_diff());
557        assert!(DiffDisplayKind::Deletion.is_diff());
558        assert!(!DiffDisplayKind::Metadata.is_diff());
559        assert!(!DiffDisplayKind::HunkHeader.is_diff());
560    }
561
562    #[test]
563    fn formats_numbered_unified_diff_with_single_gutter() {
564        let diff = "\
565@@ -10,2 +10,2 @@
566 old
567-old
568+new
569";
570        let lines = format_numbered_unified_diff(diff);
571        assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
572        // Context: blank sign + new number (width clamps to 5).
573        assert!(lines.iter().any(|line| line.contains("    10 │ old")));
574        // Deletion: old number.
575        assert!(lines.iter().any(|line| line.contains("-   11 │ old")));
576        // Addition: new number.
577        assert!(lines.iter().any(|line| line.contains("+   11 │ new")));
578    }
579
580    #[test]
581    fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
582        let line = DiffDisplayLine::body(
583            DiffDisplayKind::Addition,
584            None,
585            Some(53),
586            "- **Agent-first by design*: prose".to_string(),
587        );
588        let text = line.numbered_text(5);
589        assert_eq!(text, "+   53 │ - **Agent-first by design*: prose");
590    }
591
592    #[test]
593    fn display_lines_from_hunks_preserves_semantics() {
594        let hunks = vec![DiffHunk {
595            old_start: 10,
596            old_lines: 2,
597            new_start: 10,
598            new_lines: 2,
599            lines: vec![
600                DiffLine {
601                    kind: DiffLineKind::Deletion,
602                    old_line: Some(10),
603                    new_line: None,
604                    text: "old\n".to_string(),
605                },
606                DiffLine {
607                    kind: DiffLineKind::Addition,
608                    old_line: None,
609                    new_line: Some(10),
610                    text: "new\n".to_string(),
611                },
612                DiffLine {
613                    kind: DiffLineKind::Context,
614                    old_line: Some(11),
615                    new_line: Some(11),
616                    text: "same\n".to_string(),
617                },
618            ],
619        }];
620
621        let lines = display_lines_from_hunks(&hunks);
622        assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
623        assert_eq!(lines[0].text, "@@ -10 +10 @@");
624        assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
625        assert_eq!(lines[1].old_line, Some(10));
626        assert_eq!(lines[1].new_line, None);
627        assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
628        assert_eq!(lines[2].old_line, None);
629        assert_eq!(lines[2].new_line, Some(10));
630        assert_eq!(lines[3].kind, DiffDisplayKind::Context);
631        assert_eq!(lines[3].old_line, Some(11));
632        assert_eq!(lines[3].new_line, Some(11));
633    }
634
635    #[test]
636    fn metadata_lines_stay_metadata_after_hunk() {
637        let diff = "\
638@@ -1 +1 @@
639-old
640\\ No newline at end of file
641+new
642";
643
644        let lines = display_lines_from_unified_diff(diff);
645        assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
646        assert_eq!(lines[3].kind, DiffDisplayKind::Addition);
647        assert_eq!(lines[3].new_line, Some(1));
648    }
649
650    #[test]
651    fn diff_display_line_number_width_tracks_max_digits() {
652        let lines = vec![
653            DiffDisplayLine::body(DiffDisplayKind::Addition, None, Some(99), "let a = 1;".to_string()),
654            DiffDisplayLine::body(DiffDisplayKind::Context, Some(1), Some(10_420), "let b = 2;".to_string()),
655        ];
656
657        assert_eq!(diff_display_line_number_width(&lines), 5);
658    }
659
660    #[test]
661    fn diff_display_line_number_width_clamps_to_bounds() {
662        let small = vec![DiffDisplayLine::body(
663            DiffDisplayKind::Context,
664            Some(1),
665            Some(1),
666            "text".to_string(),
667        )];
668        assert_eq!(diff_display_line_number_width(&small), 5);
669
670        let large = vec![DiffDisplayLine::body(
671            DiffDisplayKind::Context,
672            Some(100_000),
673            Some(100_000),
674            "text".to_string(),
675        )];
676        assert_eq!(diff_display_line_number_width(&large), 6);
677    }
678
679    #[test]
680    fn word_level_diff_highlights_only_changed_tokens() {
681        let old = "let bright_red = anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed);";
682        let new = "let bright_red = anstyle::Color::Rgb(anstyle::RgbColor(255, 90, 90));";
683        let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
684        assert!(!old_ranges.is_empty());
685        assert!(!new_ranges.is_empty());
686        let old_changed: String = old_ranges.iter().map(|&(s, e)| &old[s..e]).collect();
687        let new_changed: String = new_ranges.iter().map(|&(s, e)| &new[s..e]).collect();
688        assert!(old_changed.contains("Ansi"));
689        assert!(new_changed.contains("Rgb"));
690        assert!(!old_changed.contains("bright_red"));
691        assert!(!new_changed.contains("bright_red"));
692    }
693
694    #[test]
695    fn word_level_diff_skips_dissimilar_pairs() {
696        let old = "| Pillar | What it means |\n| --- | --- |\n| **Harness** | The model reasons; the harness composes tools. |";
697        let new = "- **The loop is the product.** Tool composition, context management, and\n  verification are engineered — not improvised around a chat completion.";
698        let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
699        assert!(old_ranges.is_empty(), "dissimilar del pair should stay line-only");
700        assert!(new_ranges.is_empty(), "dissimilar add pair should stay line-only");
701    }
702
703    #[test]
704    fn word_level_diff_skips_blank_sides() {
705        let (old_ranges, new_ranges) = word_level_changed_ranges("moved block", "");
706        assert!(old_ranges.is_empty());
707        assert!(new_ranges.is_empty());
708    }
709
710    #[test]
711    fn word_level_diff_skips_oversized_atom_lists() {
712        let long_a = "a ".repeat(MAX_WORD_ATOMS + 10);
713        let long_b = "b ".repeat(MAX_WORD_ATOMS + 10);
714        let (old_ranges, new_ranges) = word_level_changed_ranges(&long_a, &long_b);
715        assert!(old_ranges.is_empty());
716        assert!(new_ranges.is_empty());
717    }
718
719    #[test]
720    fn side_by_side_pairs_context_on_both_columns() {
721        let lines = display_lines_from_hunks(&[DiffHunk {
722            old_start: 1,
723            old_lines: 1,
724            new_start: 1,
725            new_lines: 1,
726            lines: vec![DiffLine {
727                kind: DiffLineKind::Context,
728                old_line: Some(1),
729                new_line: Some(1),
730                text: "same\n".to_string(),
731            }],
732        }]);
733        let rows = side_by_side_rows(&lines);
734        assert_eq!(rows.len(), 2);
735        let context = &rows[1];
736        assert_eq!(context.left.as_ref().map(|l| l.text.as_str()), Some("same"));
737        assert_eq!(context.right.as_ref().map(|l| l.text.as_str()), Some("same"));
738    }
739
740    #[test]
741    fn side_by_side_zips_del_add_runs() {
742        let lines = display_lines_from_unified_diff(
743            "\
744@@ -1,2 +1,2 @@
745-old a
746-old b
747+new a
748+new b
749",
750        );
751        let rows = side_by_side_rows(&lines);
752        assert!(rows[0].is_full_width());
753        assert_eq!(rows[1].left.as_ref().map(|l| l.text.as_str()), Some("old a"));
754        assert_eq!(rows[1].right.as_ref().map(|l| l.text.as_str()), Some("new a"));
755        assert_eq!(rows[2].left.as_ref().map(|l| l.text.as_str()), Some("old b"));
756        assert_eq!(rows[2].right.as_ref().map(|l| l.text.as_str()), Some("new b"));
757    }
758
759    #[test]
760    fn side_by_side_keeps_unpaired_additions_on_right_only() {
761        let lines = display_lines_from_unified_diff(
762            "\
763@@ -1 +1,2 @@
764 keep
765+added
766",
767        );
768        let rows = side_by_side_rows(&lines);
769        assert!(
770            rows.iter()
771                .any(|row| row.left.is_none() && row.right.as_ref().is_some_and(|r| r.text == "added"))
772        );
773    }
774
775    #[test]
776    fn side_by_side_keeps_unpaired_deletions_on_left_only() {
777        let lines = display_lines_from_unified_diff(
778            "\
779@@ -2,1 +1 @@
780 keep
781-removed
782",
783        );
784        let rows = side_by_side_rows(&lines);
785        assert!(
786            rows.iter()
787                .any(|row| row.right.is_none() && row.left.as_ref().is_some_and(|l| l.text == "removed"))
788        );
789    }
790
791    #[test]
792    fn annotate_pairs_consecutive_del_add_runs() {
793        let diff = "\
794@@ -1 +1 @@
795-let a = 1;
796+let a = 2;
797";
798        let lines = display_lines_from_unified_diff(diff);
799        let del = lines.iter().find(|l| l.kind == DiffDisplayKind::Deletion).unwrap();
800        let add = lines.iter().find(|l| l.kind == DiffDisplayKind::Addition).unwrap();
801        assert!(!del.changed.is_empty());
802        assert!(!add.changed.is_empty());
803        assert_eq!(&del.text[del.changed[0].0..del.changed[0].1], "1");
804        assert_eq!(&add.text[add.changed[0].0..add.changed[0].1], "2");
805    }
806
807    #[test]
808    fn word_chip_user_example_pair() {
809        let old = "wrong, these are the defaults you were missing.";
810        let new = "wrong, these are the defaults you were missing — built in, not bolted on.";
811        let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
812        assert!(old_ranges.is_empty(), "shared prefix should stay line-only");
813        assert!(!new_ranges.is_empty(), "addition suffix should be a chip");
814        let new_changed: String = new_ranges.iter().map(|&(s, e)| &new[s..e]).collect();
815        assert!(new_changed.contains("built"), "got {new_changed:?}");
816    }
817}