Skip to main content

vtcode_diff/
lib.rs

1#![warn(missing_docs)]
2#![expect(
3    clippy::indexing_slicing,
4    clippy::string_slice,
5    reason = "validated diff indexes and UTF-8 token boundaries are structural invariants"
6)]
7//! Bounded structured text diffs and renderer-neutral preview rows.
8
9use similar::{ChangeTag, TextDiff};
10use std::collections::VecDeque;
11use std::fmt;
12use std::time::{Duration, Instant};
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15/// A contiguous borrowed character-level diff chunk.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Chunk<'a> {
18    /// Text present on both sides.
19    Equal(&'a str),
20    /// Text present only on the old side.
21    Delete(&'a str),
22    /// Text present only on the new side.
23    Insert(&'a str),
24}
25
26/// Algorithms suitable for interactive diff previews.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
30pub enum DiffAlgorithm {
31    /// Practical, heuristic Myers diff.
32    #[default]
33    Myers,
34    /// Patience diff, useful for source code with unique anchors.
35    Patience,
36    /// Histogram diff, useful for repeated source-code lines.
37    Histogram,
38}
39
40impl DiffAlgorithm {
41    fn similar(self) -> similar::Algorithm {
42        match self {
43            Self::Myers => similar::Algorithm::Myers,
44            Self::Patience => similar::Algorithm::Patience,
45            Self::Histogram => similar::Algorithm::Histogram,
46        }
47    }
48}
49
50/// Options controlling diff generation.
51#[derive(Debug, Clone)]
52pub struct DiffOptions<'a> {
53    /// Number of unchanged lines retained around a change.
54    pub context_lines: usize,
55    /// Optional old-side label used by formatters.
56    pub old_label: Option<&'a str>,
57    /// Optional new-side label used by formatters.
58    pub new_label: Option<&'a str>,
59    /// Whether formatters should emit missing-final-newline markers.
60    pub missing_newline_hint: bool,
61    /// Line diff algorithm.
62    pub algorithm: DiffAlgorithm,
63    /// Maximum line-diff computation time.
64    pub timeout: Duration,
65    /// Maximum total time spent on intraline refinement.
66    pub inline_timeout: Duration,
67}
68
69impl Default for DiffOptions<'_> {
70    fn default() -> Self {
71        Self {
72            context_lines: 3,
73            old_label: None,
74            new_label: None,
75            missing_newline_hint: true,
76            algorithm: DiffAlgorithm::Myers,
77            timeout: Duration::from_millis(200),
78            inline_timeout: Duration::from_millis(40),
79        }
80    }
81}
82
83/// A diff hunk with old/new ranges and semantic lines.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct DiffHunk {
87    /// One-based old-side start line.
88    pub old_start: usize,
89    /// Number of represented old-side lines.
90    pub old_lines: usize,
91    /// One-based new-side start line.
92    pub new_start: usize,
93    /// Number of represented new-side lines.
94    pub new_lines: usize,
95    /// Lines contained in the hunk.
96    pub lines: Vec<DiffLine>,
97}
98
99/// The semantic role of a line inside a hunk.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
103pub enum DiffLineKind {
104    /// Unchanged context.
105    Context,
106    /// New-side insertion.
107    Addition,
108    /// Old-side deletion.
109    Deletion,
110}
111
112/// A source line and its old/new positions.
113#[derive(Debug, Clone, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115pub struct DiffLine {
116    /// Semantic line role.
117    pub kind: DiffLineKind,
118    /// One-based old-side line number, when present.
119    pub old_line: Option<u32>,
120    /// One-based new-side line number, when present.
121    pub new_line: Option<u32>,
122    /// Source text, including its original line terminator when present.
123    pub text: String,
124}
125
126/// Aggregate statistics for a complete document.
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct DiffStats {
130    /// Added lines.
131    pub additions: usize,
132    /// Deleted lines.
133    pub deletions: usize,
134    /// Context lines represented by hunks.
135    pub context: usize,
136    /// Number of hunks.
137    pub hunks: usize,
138    /// Semantic rows omitted by a bounded layout.
139    pub omitted_rows: usize,
140}
141
142/// A complete renderer-independent diff.
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct DiffDocument {
146    /// Structured hunks.
147    pub hunks: Vec<DiffHunk>,
148    /// Whole-document statistics.
149    pub stats: DiffStats,
150    #[cfg_attr(feature = "serde", serde(default = "default_inline_timeout"))]
151    inline_timeout: Duration,
152}
153
154impl Default for DiffDocument {
155    fn default() -> Self {
156        Self {
157            hunks: Vec::new(),
158            stats: DiffStats::default(),
159            inline_timeout: default_inline_timeout(),
160        }
161    }
162}
163
164impl DiffDocument {
165    /// Computes a bounded diff from before/after text.
166    #[must_use]
167    pub fn between(old: &str, new: &str, options: DiffOptions<'_>) -> Self {
168        let old_lines = split_lines_with_terminator(old);
169        let new_lines = split_lines_with_terminator(new);
170        let old_line_refs: Vec<&str> = old_lines.iter().map(String::as_str).collect();
171        let new_line_refs: Vec<&str> = new_lines.iter().map(String::as_str).collect();
172        let mut config = TextDiff::configure();
173        let _ = config.algorithm(options.algorithm.similar()).timeout(options.timeout);
174        let diff = config.diff_slices(&old_line_refs, &new_line_refs);
175        let mut hunks = Vec::new();
176
177        for group in diff.grouped_ops(options.context_lines) {
178            let Some(_first) = group.first() else {
179                continue;
180            };
181            let mut lines = Vec::new();
182            for operation in &group {
183                for change in diff.iter_changes(operation) {
184                    let (kind, old_line, new_line) = match change.tag() {
185                        ChangeTag::Equal => (
186                            DiffLineKind::Context,
187                            change.old_index().and_then(one_based_u32),
188                            change.new_index().and_then(one_based_u32),
189                        ),
190                        ChangeTag::Delete => (DiffLineKind::Deletion, change.old_index().and_then(one_based_u32), None),
191                        ChangeTag::Insert => (DiffLineKind::Addition, None, change.new_index().and_then(one_based_u32)),
192                    };
193                    lines.push(DiffLine {
194                        kind,
195                        old_line,
196                        new_line,
197                        text: change.value().to_owned(),
198                    });
199                }
200            }
201            let old_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Addition).count();
202            let new_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Deletion).count();
203            let old_start = hunk_start_from_lines(&lines, true);
204            let new_start = hunk_start_from_lines(&lines, false);
205            hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
206        }
207        let mut document = Self::from_hunks(hunks);
208        document.inline_timeout = options.inline_timeout;
209        document
210    }
211
212    /// Constructs a document from precomputed hunks.
213    #[must_use]
214    pub fn from_hunks(hunks: Vec<DiffHunk>) -> Self {
215        let mut stats = DiffStats { hunks: hunks.len(), ..DiffStats::default() };
216        for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
217            match line.kind {
218                DiffLineKind::Context => stats.context += 1,
219                DiffLineKind::Addition => stats.additions += 1,
220                DiffLineKind::Deletion => stats.deletions += 1,
221            }
222        }
223        Self {
224            hunks,
225            stats,
226            inline_timeout: default_inline_timeout(),
227        }
228    }
229
230    /// Parses unified-diff text into a structured document.
231    ///
232    /// File metadata is accepted and ignored. A body line before the first
233    /// valid hunk header is rejected instead of receiving invented numbers.
234    pub fn from_unified(input: &str) -> Result<Self, ParseDiffError> {
235        let mut hunks = Vec::new();
236        let mut current: Option<DiffHunk> = None;
237        let mut old_line = 0u32;
238        let mut new_line = 0u32;
239        let mut expected_old_lines = 0usize;
240        let mut expected_new_lines = 0usize;
241        let mut omitted_in_current_hunk = false;
242        let mut omitted_rows = 0usize;
243        let mut omitted_tail = Vec::new();
244
245        for raw_with_ending in split_line_slices(input) {
246            let raw = trim_line_ending(raw_with_ending);
247            if raw.starts_with("@@") {
248                if let Some(mut hunk) = current.take() {
249                    assign_omitted_hunk_tail_line_numbers(
250                        &mut hunk,
251                        &omitted_tail,
252                        expected_old_lines,
253                        expected_new_lines,
254                    );
255                    if !omitted_in_current_hunk {
256                        validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
257                    }
258                    hunks.push(hunk);
259                }
260                omitted_tail.clear();
261                let (old_start, old_count, new_start, new_count) =
262                    parse_hunk_range(raw).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
263                old_line = old_start;
264                new_line = new_start;
265                expected_old_lines = old_count;
266                expected_new_lines = new_count;
267                omitted_in_current_hunk = false;
268                current = Some(DiffHunk {
269                    old_start: old_start as usize,
270                    old_lines: 0,
271                    new_start: new_start as usize,
272                    new_lines: 0,
273                    lines: Vec::new(),
274                });
275                continue;
276            }
277            if raw.starts_with("diff ")
278                || raw.starts_with("index ")
279                || raw == r"\ No newline at end of file"
280                || raw.is_empty()
281            {
282                continue;
283            }
284            // Git's file/mode headers precede the first hunk in ordinary
285            // unified output. Keep them out of the body parser while still
286            // rejecting an actual `-...`/`+...` body before any hunk.
287            let hunk_complete = current
288                .as_ref()
289                .is_some_and(|hunk| hunk.old_lines >= expected_old_lines && hunk.new_lines >= expected_new_lines);
290            if (current.is_none() || hunk_complete) && is_unified_metadata_line(raw) {
291                continue;
292            }
293            if hunk_complete && (raw.starts_with("--- ") || raw.starts_with("+++ ")) {
294                continue;
295            }
296            let Some(hunk) = current.as_mut() else {
297                return Err(ParseDiffError::new("diff body appears before a hunk header"));
298            };
299            let is_body_line = matches!(raw.as_bytes().first().copied(), Some(b'-' | b'+' | b' '));
300            if !is_body_line && let Some(omitted) = parse_omitted_line_count(raw) {
301                omitted_in_current_hunk = true;
302                let marker_rows = omitted;
303                let advance = marker_rows
304                    .min(expected_old_lines.saturating_sub(hunk.old_lines))
305                    .min(expected_new_lines.saturating_sub(hunk.new_lines));
306                old_line = old_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
307                new_line = new_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
308                hunk.old_lines = hunk.old_lines.saturating_add(advance);
309                hunk.new_lines = hunk.new_lines.saturating_add(advance);
310                omitted_rows = omitted_rows.saturating_add(marker_rows);
311                continue;
312            }
313            let (kind, old_number, new_number) = match raw.as_bytes().first().copied() {
314                Some(b'-') => {
315                    if hunk.old_lines >= expected_old_lines {
316                        return Err(ParseDiffError::new("too many old-side lines for hunk header"));
317                    }
318                    let number = old_line;
319                    old_line = old_line.saturating_add(1);
320                    hunk.old_lines += 1;
321                    (DiffLineKind::Deletion, Some(number), None)
322                }
323                Some(b'+') => {
324                    if hunk.new_lines >= expected_new_lines {
325                        return Err(ParseDiffError::new("too many new-side lines for hunk header"));
326                    }
327                    let number = new_line;
328                    new_line = new_line.saturating_add(1);
329                    hunk.new_lines += 1;
330                    (DiffLineKind::Addition, None, Some(number))
331                }
332                Some(b' ') => {
333                    if hunk.old_lines >= expected_old_lines || hunk.new_lines >= expected_new_lines {
334                        return Err(ParseDiffError::new("too many context lines for hunk header"));
335                    }
336                    let old_number = old_line;
337                    let new_number = new_line;
338                    old_line = old_line.saturating_add(1);
339                    new_line = new_line.saturating_add(1);
340                    hunk.old_lines += 1;
341                    hunk.new_lines += 1;
342                    (DiffLineKind::Context, Some(old_number), Some(new_number))
343                }
344                _ => return Err(ParseDiffError::new("invalid unified diff body line")),
345            };
346            hunk.lines.push(DiffLine {
347                kind,
348                old_line: old_number,
349                new_line: new_number,
350                text: raw_with_ending[1..].to_owned(),
351            });
352            if omitted_in_current_hunk {
353                omitted_tail.push(hunk.lines.len() - 1);
354            }
355        }
356        if let Some(mut hunk) = current {
357            assign_omitted_hunk_tail_line_numbers(&mut hunk, &omitted_tail, expected_old_lines, expected_new_lines);
358            if !omitted_in_current_hunk {
359                validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
360            }
361            hunks.push(hunk);
362        }
363        let mut document = Self::from_hunks(hunks);
364        document.stats.omitted_rows = omitted_rows;
365        Ok(document)
366    }
367
368    /// Lays out this document with terminal display-width wrapping.
369    #[must_use]
370    pub fn layout(&self, options: LayoutOptions) -> Vec<DiffRow> {
371        layout_document(self, options)
372    }
373}
374
375fn default_inline_timeout() -> Duration {
376    Duration::from_millis(40)
377}
378
379fn one_based_u32(index: usize) -> Option<u32> {
380    u32::try_from(index).ok()?.checked_add(1)
381}
382
383fn hunk_start_from_lines(lines: &[DiffLine], old_side: bool) -> usize {
384    let line_number = lines
385        .iter()
386        .find_map(|line| if old_side { line.old_line } else { line.new_line });
387    if let Some(line_number) = line_number {
388        return usize::try_from(line_number).unwrap_or(usize::MAX).max(1);
389    }
390
391    let counterpart = lines
392        .iter()
393        .find_map(|line| if old_side { line.new_line } else { line.old_line });
394    usize::try_from(counterpart.unwrap_or(1)).unwrap_or(usize::MAX).max(1)
395}
396
397fn validate_hunk_counts(
398    hunk: &DiffHunk,
399    expected_old_lines: usize,
400    expected_new_lines: usize,
401) -> Result<(), ParseDiffError> {
402    if hunk.old_lines == expected_old_lines && hunk.new_lines == expected_new_lines {
403        Ok(())
404    } else {
405        Err(ParseDiffError::new("hunk line counts do not match header"))
406    }
407}
408
409fn assign_omitted_hunk_tail_line_numbers(
410    hunk: &mut DiffHunk,
411    tail: &[usize],
412    expected_old_lines: usize,
413    expected_new_lines: usize,
414) {
415    if tail.is_empty() {
416        return;
417    }
418    let old_tail_count = tail
419        .iter()
420        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Addition)
421        .count();
422    let new_tail_count = tail
423        .iter()
424        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Deletion)
425        .count();
426    let mut old_line = u32::try_from(hunk.old_start)
427        .unwrap_or(u32::MAX)
428        .saturating_add(u32::try_from(expected_old_lines).unwrap_or(u32::MAX))
429        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
430    let mut new_line = u32::try_from(hunk.new_start)
431        .unwrap_or(u32::MAX)
432        .saturating_add(u32::try_from(expected_new_lines).unwrap_or(u32::MAX))
433        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
434    for &index in tail {
435        match hunk.lines[index].kind {
436            DiffLineKind::Addition => {
437                hunk.lines[index].new_line = Some(new_line);
438                new_line = new_line.saturating_add(1);
439            }
440            DiffLineKind::Deletion => {
441                hunk.lines[index].old_line = Some(old_line);
442                old_line = old_line.saturating_add(1);
443            }
444            DiffLineKind::Context => {
445                hunk.lines[index].old_line = Some(old_line);
446                hunk.lines[index].new_line = Some(new_line);
447                old_line = old_line.saturating_add(1);
448                new_line = new_line.saturating_add(1);
449            }
450        }
451    }
452}
453
454/// Error returned for malformed unified diff input.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct ParseDiffError {
457    message: &'static str,
458}
459
460impl ParseDiffError {
461    fn new(message: &'static str) -> Self {
462        Self { message }
463    }
464}
465
466impl fmt::Display for ParseDiffError {
467    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468        formatter.write_str(self.message)
469    }
470}
471
472impl std::error::Error for ParseDiffError {}
473
474/// A diff rendered with both structured hunks and formatted text.
475#[derive(Debug, Clone)]
476#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
477pub struct DiffBundle {
478    /// Structured hunks.
479    pub hunks: Vec<DiffHunk>,
480    /// Caller-formatted representation.
481    pub formatted: String,
482    /// Whether both inputs were identical.
483    pub is_empty: bool,
484}
485
486/// Computes a structured diff and passes non-empty hunks to a formatter.
487pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
488where
489    F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
490{
491    if old == new {
492        return DiffBundle {
493            hunks: Vec::new(),
494            formatted: String::new(),
495            is_empty: true,
496        };
497    }
498    let document = DiffDocument::between(old, new, options.clone());
499    let formatted = formatter(&document.hunks, &options);
500    DiffBundle { hunks: document.hunks, formatted, is_empty: false }
501}
502
503/// Formats a structured diff as plain unified text.
504///
505/// Labels are emitted when both [`DiffOptions::old_label`] and
506/// [`DiffOptions::new_label`] are present. Source line terminators are
507/// normalized to `\n` in the formatted output while missing-final-newline
508/// hints remain controlled by [`DiffOptions::missing_newline_hint`].
509#[must_use]
510pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
511    let document = DiffDocument::between(old, new, options.clone());
512    format_unified_hunks(&document.hunks, &options)
513}
514
515/// Formats precomputed hunks as plain unified text.
516#[must_use]
517pub fn format_unified_hunks(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
518    if hunks.is_empty() {
519        return String::new();
520    }
521
522    let mut output = String::new();
523    if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
524        output.push_str("--- ");
525        output.push_str(old_label);
526        output.push('\n');
527        output.push_str("+++ ");
528        output.push_str(new_label);
529        output.push('\n');
530    }
531
532    for hunk in hunks {
533        output.push_str("@@ -");
534        output.push_str(&format_unified_range(hunk.old_start, hunk.old_lines));
535        output.push_str(" +");
536        output.push_str(&format_unified_range(hunk.new_start, hunk.new_lines));
537        output.push_str(" @@\n");
538        for line in &hunk.lines {
539            let prefix = match line.kind {
540                DiffLineKind::Context => ' ',
541                DiffLineKind::Addition => '+',
542                DiffLineKind::Deletion => '-',
543            };
544            output.push(prefix);
545            output.push_str(trim_line_ending(&line.text));
546            output.push('\n');
547
548            let has_line_terminator = line.text.ends_with('\n') || line.text.ends_with('\r');
549            if options.missing_newline_hint && !has_line_terminator {
550                output.push_str(r"\ No newline at end of file");
551                output.push('\n');
552            }
553        }
554    }
555    output
556}
557
558fn format_unified_range(start: usize, count: usize) -> String {
559    if count == 0 {
560        return format!("{},0", start.saturating_sub(1));
561    }
562    if count == 1 {
563        return start.to_string();
564    }
565    format!("{start},{count}")
566}
567
568/// Computes a character-level diff with adjacent chunks coalesced.
569#[must_use]
570pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
571    if old == new {
572        return (!old.is_empty()).then_some(Chunk::Equal(old)).into_iter().collect();
573    }
574    let diff = TextDiff::configure()
575        .algorithm(similar::Algorithm::Myers)
576        .timeout(Duration::from_millis(200))
577        .diff_chars(old, new);
578    let mut chunks = Vec::new();
579    let mut old_offset = 0usize;
580    let mut new_offset = 0usize;
581    let mut run: Option<(ChangeTag, usize, usize)> = None;
582
583    for change in diff.iter_all_changes() {
584        let value = change.value();
585        let byte_len = value.len();
586        let (start, end) = match change.tag() {
587            ChangeTag::Equal | ChangeTag::Delete => (old_offset, old_offset.saturating_add(byte_len)),
588            ChangeTag::Insert => (new_offset, new_offset.saturating_add(byte_len)),
589        };
590        if let Some((tag, run_start, run_end)) = run {
591            if tag == change.tag() && run_end == start {
592                run = Some((tag, run_start, end));
593            } else {
594                push_chunk(&mut chunks, tag, run_start, run_end, old, new);
595                run = Some((change.tag(), start, end));
596            }
597        } else {
598            run = Some((change.tag(), start, end));
599        }
600        match change.tag() {
601            ChangeTag::Equal => {
602                old_offset = old_offset.saturating_add(byte_len);
603                new_offset = new_offset.saturating_add(byte_len);
604            }
605            ChangeTag::Delete => old_offset = old_offset.saturating_add(byte_len),
606            ChangeTag::Insert => new_offset = new_offset.saturating_add(byte_len),
607        }
608    }
609    if let Some((tag, start, end)) = run {
610        push_chunk(&mut chunks, tag, start, end, old, new);
611    }
612    chunks
613}
614
615fn push_chunk<'a>(chunks: &mut Vec<Chunk<'a>>, tag: ChangeTag, start: usize, end: usize, old: &'a str, new: &'a str) {
616    let chunk = match tag {
617        ChangeTag::Equal => Chunk::Equal(&old[start..end]),
618        ChangeTag::Delete => Chunk::Delete(&old[start..end]),
619        ChangeTag::Insert => Chunk::Insert(&new[start..end]),
620    };
621    chunks.push(chunk);
622}
623
624fn split_lines_with_terminator(text: &str) -> Vec<String> {
625    split_line_slices(text).into_iter().map(str::to_owned).collect()
626}
627
628fn split_line_slices(text: &str) -> Vec<&str> {
629    let mut lines = Vec::new();
630    let mut start = 0usize;
631    let bytes = text.as_bytes();
632    let mut index = 0usize;
633    while index < bytes.len() {
634        if bytes[index] != b'\n' && bytes[index] != b'\r' {
635            index += 1;
636            continue;
637        }
638        let end = if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
639            index + 2
640        } else {
641            index + 1
642        };
643        lines.push(&text[start..end]);
644        start = end;
645        index = end;
646    }
647    if start < text.len() {
648        lines.push(&text[start..]);
649    }
650    lines
651}
652
653/// Intraline byte range, always aligned to UTF-8 boundaries.
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
656pub struct IntralineRange {
657    /// Inclusive byte start.
658    pub start: usize,
659    /// Exclusive byte end.
660    pub end: usize,
661}
662
663/// Intra-line highlight ranges retained for compatibility.
664pub type WordChangedRanges = Vec<(usize, usize)>;
665
666/// Aggregate addition/deletion counts retained for compatibility.
667#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
668pub struct DiffChangeCounts {
669    /// Added lines.
670    pub additions: usize,
671    /// Deleted lines.
672    pub deletions: usize,
673}
674
675impl DiffChangeCounts {
676    /// Total changed lines.
677    #[must_use]
678    pub const fn total(self) -> usize {
679        self.additions + self.deletions
680    }
681}
682
683/// Counts additions and deletions in structured hunks.
684#[must_use]
685pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
686    let mut counts = DiffChangeCounts::default();
687    for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
688        match line.kind {
689            DiffLineKind::Addition => counts.additions += 1,
690            DiffLineKind::Deletion => counts.deletions += 1,
691            DiffLineKind::Context => {}
692        }
693    }
694    counts
695}
696
697/// Semantic role used by legacy preview consumers.
698#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
699pub enum DiffDisplayKind {
700    /// File or parser metadata.
701    Metadata,
702    /// Hunk range header.
703    HunkHeader,
704    /// Unchanged context.
705    Context,
706    /// Added content.
707    Addition,
708    /// Deleted content.
709    Deletion,
710}
711
712impl DiffDisplayKind {
713    /// Whether the kind represents source content.
714    #[must_use]
715    pub const fn is_diff(self) -> bool {
716        matches!(self, Self::Context | Self::Addition | Self::Deletion)
717    }
718}
719
720/// A semantic legacy display line.
721#[derive(Clone, Debug, Eq, PartialEq)]
722pub struct DiffDisplayLine {
723    /// Semantic role.
724    pub kind: DiffDisplayKind,
725    /// Old-side number.
726    pub old_line: Option<u32>,
727    /// New-side number.
728    pub new_line: Option<u32>,
729    /// Content without the diff marker or line ending.
730    pub text: String,
731    /// Intraline changed byte ranges.
732    pub changed: WordChangedRanges,
733}
734
735impl DiffDisplayLine {
736    /// Constructs a source body line.
737    #[must_use]
738    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
739        Self {
740            kind,
741            old_line,
742            new_line,
743            text,
744            changed: Vec::new(),
745        }
746    }
747
748    /// Whether this line represents source content.
749    #[must_use]
750    pub const fn is_diff(&self) -> bool {
751        self.kind.is_diff()
752    }
753
754    /// Formats a single numbered gutter.
755    #[must_use]
756    pub fn numbered_text(&self, line_number_width: usize) -> String {
757        match self.kind {
758            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
759            DiffDisplayKind::Deletion => {
760                format!("-{:>width$} │ {}", self.old_line.unwrap_or_default(), self.text, width = line_number_width)
761            }
762            DiffDisplayKind::Addition => {
763                format!("+{:>width$} │ {}", self.new_line.unwrap_or_default(), self.text, width = line_number_width)
764            }
765            DiffDisplayKind::Context => format!(
766                " {:>width$} │ {}",
767                self.new_line.or(self.old_line).unwrap_or_default(),
768                self.text,
769                width = line_number_width
770            ),
771        }
772    }
773}
774
775/// Converts hunks to semantic display lines and bounded intraline ranges.
776#[must_use]
777pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
778    display_lines_from_hunks_with_timeout(hunks, default_inline_timeout())
779}
780
781fn display_lines_from_hunks_with_timeout(hunks: &[DiffHunk], inline_timeout: Duration) -> Vec<DiffDisplayLine> {
782    let mut output = Vec::new();
783    for hunk in hunks {
784        output.push(DiffDisplayLine::body(
785            DiffDisplayKind::HunkHeader,
786            None,
787            None,
788            format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
789        ));
790        output.extend(hunk.lines.iter().map(|line| {
791            let kind = match line.kind {
792                DiffLineKind::Context => DiffDisplayKind::Context,
793                DiffLineKind::Addition => DiffDisplayKind::Addition,
794                DiffLineKind::Deletion => DiffDisplayKind::Deletion,
795            };
796            DiffDisplayLine::body(kind, line.old_line, line.new_line, trim_line_ending(&line.text).to_owned())
797        }));
798    }
799    annotate_word_level_diffs_with_timeout(&mut output, inline_timeout);
800    output
801}
802
803/// Parses unified text into display lines, retaining metadata lines.
804#[must_use]
805pub fn display_lines_from_unified_diff(input: &str) -> Vec<DiffDisplayLine> {
806    let mut output = Vec::new();
807    let mut old_line = 0u32;
808    let mut new_line = 0u32;
809    let mut in_hunk = false;
810    let mut remaining_old = 0usize;
811    let mut remaining_new = 0usize;
812    let mut omission_in_hunk = false;
813    let mut hunk_old_start = 0u32;
814    let mut hunk_new_start = 0u32;
815    let mut hunk_old_count = 0usize;
816    let mut hunk_new_count = 0usize;
817    let mut omitted_tail = Vec::new();
818    for raw in input.lines() {
819        if let Some((old_start, new_start)) = parse_hunk_starts(raw) {
820            assign_omitted_tail_line_numbers(
821                &mut output,
822                &omitted_tail,
823                hunk_old_start,
824                hunk_old_count,
825                hunk_new_start,
826                hunk_new_count,
827            );
828            omitted_tail.clear();
829            old_line = old_start;
830            new_line = new_start;
831            if let Some((_, old_count, _, new_count)) = parse_hunk_range(raw) {
832                remaining_old = old_count;
833                remaining_new = new_count;
834                hunk_old_count = old_count;
835                hunk_new_count = new_count;
836            }
837            hunk_old_start = old_start;
838            hunk_new_start = new_start;
839            in_hunk = true;
840            omission_in_hunk = false;
841            output.push(DiffDisplayLine::body(
842                DiffDisplayKind::HunkHeader,
843                None,
844                None,
845                format!("@@ -{old_start} +{new_start} @@"),
846            ));
847        } else if in_hunk && (omission_in_hunk || remaining_new > 0) && raw.starts_with('+') {
848            let output_index = output.len();
849            output.push(DiffDisplayLine::body(
850                DiffDisplayKind::Addition,
851                None,
852                (!omission_in_hunk).then_some(new_line),
853                raw[1..].to_owned(),
854            ));
855            if omission_in_hunk {
856                omitted_tail.push(output_index);
857            }
858            new_line = new_line.saturating_add(1);
859            remaining_new = remaining_new.saturating_sub(1);
860        } else if in_hunk && (omission_in_hunk || remaining_old > 0) && raw.starts_with('-') {
861            let output_index = output.len();
862            output.push(DiffDisplayLine::body(
863                DiffDisplayKind::Deletion,
864                (!omission_in_hunk).then_some(old_line),
865                None,
866                raw[1..].to_owned(),
867            ));
868            if omission_in_hunk {
869                omitted_tail.push(output_index);
870            }
871            old_line = old_line.saturating_add(1);
872            remaining_old = remaining_old.saturating_sub(1);
873        } else if in_hunk && (omission_in_hunk || (remaining_old > 0 && remaining_new > 0)) && raw.starts_with(' ') {
874            let output_index = output.len();
875            output.push(DiffDisplayLine::body(
876                DiffDisplayKind::Context,
877                (!omission_in_hunk).then_some(old_line),
878                (!omission_in_hunk).then_some(new_line),
879                raw[1..].to_owned(),
880            ));
881            if omission_in_hunk {
882                omitted_tail.push(output_index);
883            }
884            old_line = old_line.saturating_add(1);
885            new_line = new_line.saturating_add(1);
886            remaining_old = remaining_old.saturating_sub(1);
887            remaining_new = remaining_new.saturating_sub(1);
888        } else if in_hunk && let Some(omitted) = parse_omitted_line_count(raw) {
889            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
890            omission_in_hunk = true;
891            let omitted = omitted.min(remaining_old).min(remaining_new);
892            old_line = old_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
893            new_line = new_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
894            remaining_old = remaining_old.saturating_sub(omitted);
895            remaining_new = remaining_new.saturating_sub(omitted);
896        } else {
897            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
898        }
899    }
900    assign_omitted_tail_line_numbers(
901        &mut output,
902        &omitted_tail,
903        hunk_old_start,
904        hunk_old_count,
905        hunk_new_start,
906        hunk_new_count,
907    );
908    annotate_word_level_diffs(&mut output);
909    output
910}
911
912fn assign_omitted_tail_line_numbers(
913    lines: &mut [DiffDisplayLine],
914    tail: &[usize],
915    old_start: u32,
916    old_count: usize,
917    new_start: u32,
918    new_count: usize,
919) {
920    if tail.is_empty() {
921        return;
922    }
923    let old_tail_count = tail
924        .iter()
925        .filter(|&&index| lines[index].kind != DiffDisplayKind::Addition)
926        .count();
927    let new_tail_count = tail
928        .iter()
929        .filter(|&&index| lines[index].kind != DiffDisplayKind::Deletion)
930        .count();
931    let mut old_line = old_start
932        .saturating_add(u32::try_from(old_count).unwrap_or(u32::MAX))
933        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
934    let mut new_line = new_start
935        .saturating_add(u32::try_from(new_count).unwrap_or(u32::MAX))
936        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
937    for &index in tail {
938        match lines[index].kind {
939            DiffDisplayKind::Addition => {
940                lines[index].new_line = Some(new_line);
941                new_line = new_line.saturating_add(1);
942            }
943            DiffDisplayKind::Deletion => {
944                lines[index].old_line = Some(old_line);
945                old_line = old_line.saturating_add(1);
946            }
947            DiffDisplayKind::Context => {
948                lines[index].old_line = Some(old_line);
949                lines[index].new_line = Some(new_line);
950                old_line = old_line.saturating_add(1);
951                new_line = new_line.saturating_add(1);
952            }
953            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => {}
954        }
955    }
956}
957
958/// Formats a numbered unified diff without ANSI color.
959#[must_use]
960pub fn format_numbered_unified_diff(input: &str) -> Vec<String> {
961    let lines = display_lines_from_unified_diff(input);
962    let width = diff_display_line_number_width(&lines);
963    lines.iter().map(|line| line.numbered_text(width)).collect()
964}
965
966/// Computes the clamped line-number gutter width.
967#[must_use]
968pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
969    let maximum = lines
970        .iter()
971        .flat_map(|line| [line.old_line, line.new_line])
972        .flatten()
973        .max()
974        .unwrap_or_default();
975    decimal_digits(maximum).clamp(5, 6)
976}
977
978/// Minimum content width for the paired old/new preview.
979///
980/// Below this width, two independently numbered panes leave too little room
981/// for source text. Renderers should fall back to the unified presentation.
982pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
983
984/// Whether a measured width can support the paired old/new preview.
985///
986/// An unknown width preserves the existing caller behavior; redirected
987/// output and test sinks may not expose terminal sizing at all.
988#[must_use]
989pub const fn diff_side_by_side_fits(available_width: Option<usize>) -> bool {
990    match available_width {
991        Some(width) => width >= DIFF_MIN_SIDE_BY_SIDE_WIDTH,
992        None => true,
993    }
994}
995
996/// Minimum source width retained when the unified line gutter is visible.
997///
998/// This keeps the marker, line number, separator, and a useful amount of
999/// source text together. Narrower layouts should hide the gutter and give its
1000/// columns back to the source body.
1001pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
1002
1003/// Width consumed by a unified diff gutter after the line-number field.
1004///
1005/// The rendered shape is `+123 │ `: one marker plus the three-cell separator
1006/// around `│`. The line-number field is supplied by the caller because it is
1007/// derived from the visible diff excerpt.
1008#[must_use]
1009pub const fn diff_gutter_width(line_number_width: usize) -> usize {
1010    line_number_width.saturating_add(4)
1011}
1012
1013/// Whether a unified diff can keep its marker, line number, and separator
1014/// without starving the source body.
1015#[must_use]
1016pub const fn diff_gutter_fits(available_width: usize, line_number_width: usize) -> bool {
1017    available_width >= diff_gutter_width(line_number_width).saturating_add(DIFF_MIN_BODY_WIDTH_WITH_GUTTER)
1018}
1019
1020/// Width to pass to semantic unified layout when the renderer hides its
1021/// visible gutter.
1022///
1023/// `layout_display_lines` subtracts the normal gutter before wrapping source
1024/// text. Giving that width back keeps wrapping aligned with compact rendering
1025/// without adding another public layout option.
1026#[must_use]
1027pub const fn diff_layout_width(available_width: usize, line_number_width: usize, show_gutter: bool) -> usize {
1028    if show_gutter {
1029        available_width
1030    } else {
1031        available_width.saturating_add(diff_gutter_width(line_number_width))
1032    }
1033}
1034
1035fn decimal_digits(mut number: u32) -> usize {
1036    let mut digits = 1usize;
1037    while number >= 10 {
1038        number /= 10;
1039        digits += 1;
1040    }
1041    digits
1042}
1043
1044/// One paired row in the compatibility side-by-side model.
1045#[derive(Clone, Debug, Eq, PartialEq)]
1046pub struct SideBySideRow {
1047    /// Old-side cell.
1048    pub left: Option<DiffDisplayLine>,
1049    /// New-side cell.
1050    pub right: Option<DiffDisplayLine>,
1051}
1052
1053impl SideBySideRow {
1054    /// Whether the left cell is a header spanning both panes.
1055    #[must_use]
1056    pub fn is_full_width(&self) -> bool {
1057        self.right.is_none()
1058            && self
1059                .left
1060                .as_ref()
1061                .is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
1062    }
1063}
1064
1065/// Pairs deletions and additions for side-by-side presentation.
1066#[must_use]
1067pub fn side_by_side_rows(lines: &[DiffDisplayLine]) -> Vec<SideBySideRow> {
1068    let mut rows = Vec::new();
1069    for_each_side_by_side_pair(lines, |left, right| {
1070        rows.push(SideBySideRow { left: left.cloned(), right: right.cloned() });
1071    });
1072    rows
1073}
1074
1075fn for_each_side_by_side_pair<'a, F>(lines: &'a [DiffDisplayLine], mut visit: F)
1076where
1077    F: FnMut(Option<&'a DiffDisplayLine>, Option<&'a DiffDisplayLine>),
1078{
1079    let mut index = 0usize;
1080    while index < lines.len() {
1081        match lines[index].kind {
1082            DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata => {
1083                visit(Some(&lines[index]), None);
1084                index += 1;
1085            }
1086            DiffDisplayKind::Context => {
1087                let line = &lines[index];
1088                visit(Some(line), Some(line));
1089                index += 1;
1090            }
1091            DiffDisplayKind::Deletion => {
1092                let delete_start = index;
1093                while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1094                    index += 1;
1095                }
1096                let insert_start = index;
1097                while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1098                    index += 1;
1099                }
1100                let delete_count = insert_start - delete_start;
1101                let insert_count = index - insert_start;
1102                for offset in 0..delete_count.max(insert_count) {
1103                    visit(
1104                        (offset < delete_count).then(|| &lines[delete_start + offset]),
1105                        (offset < insert_count).then(|| &lines[insert_start + offset]),
1106                    );
1107                }
1108            }
1109            DiffDisplayKind::Addition => {
1110                visit(None, Some(&lines[index]));
1111                index += 1;
1112            }
1113        }
1114    }
1115}
1116
1117/// Adds byte-safe intraline ranges to consecutive deletion/addition groups.
1118pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
1119    annotate_word_level_diffs_with_timeout(lines, default_inline_timeout());
1120}
1121
1122fn annotate_word_level_diffs_with_timeout(lines: &mut [DiffDisplayLine], timeout: Duration) {
1123    // Binary content (NUL bytes) makes word-level refinement meaningless and
1124    // expensive; skip it entirely so intraline work stays within budget.
1125    if lines.iter().any(|line| line.text.as_bytes().contains(&0)) {
1126        return;
1127    }
1128    let deadline = Instant::now().checked_add(timeout);
1129    let mut index = 0usize;
1130    while index < lines.len() {
1131        if lines[index].kind != DiffDisplayKind::Deletion {
1132            index += 1;
1133            continue;
1134        }
1135        let delete_start = index;
1136        while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1137            index += 1;
1138        }
1139        let insert_start = index;
1140        while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1141            index += 1;
1142        }
1143        let pair_count = (insert_start - delete_start).min(index - insert_start);
1144        for offset in 0..pair_count {
1145            if deadline.is_some_and(|limit| Instant::now() >= limit) {
1146                return;
1147            }
1148            let (old_ranges, new_ranges) =
1149                word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
1150            lines[delete_start + offset].changed = old_ranges;
1151            lines[insert_start + offset].changed = new_ranges;
1152        }
1153    }
1154}
1155
1156/// Computes byte-safe word-level changed ranges for a line pair.
1157#[must_use]
1158pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
1159    if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
1160        return (Vec::new(), Vec::new());
1161    }
1162    let diff = TextDiff::configure()
1163        .algorithm(similar::Algorithm::Myers)
1164        .timeout(Duration::from_millis(10))
1165        .diff_unicode_words(old, new);
1166    if diff.ratio() < 0.35 {
1167        return (Vec::new(), Vec::new());
1168    }
1169    let mut old_offset = 0usize;
1170    let mut new_offset = 0usize;
1171    let mut old_ranges = Vec::new();
1172    let mut new_ranges = Vec::new();
1173    for change in diff.iter_all_changes() {
1174        let length = change.value().len();
1175        match change.tag() {
1176            ChangeTag::Equal => {
1177                old_offset += length;
1178                new_offset += length;
1179            }
1180            ChangeTag::Delete => {
1181                push_range(&mut old_ranges, old_offset, old_offset + length);
1182                old_offset += length;
1183            }
1184            ChangeTag::Insert => {
1185                push_range(&mut new_ranges, new_offset, new_offset + length);
1186                new_offset += length;
1187            }
1188        }
1189    }
1190    (old_ranges, new_ranges)
1191}
1192
1193fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
1194    match ranges.last_mut() {
1195        Some((_, prior_end)) if *prior_end == start => *prior_end = end,
1196        _ => ranges.push((start, end)),
1197    }
1198}
1199
1200/// Requested preview layout.
1201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1203#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
1204pub enum DiffLayout {
1205    /// One stacked old/new column.
1206    #[default]
1207    Unified,
1208    /// Paired old/new panes.
1209    SideBySide,
1210}
1211
1212/// Width and bounded-excerpt options for semantic layout.
1213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1214pub struct LayoutOptions {
1215    /// Requested layout.
1216    pub layout: DiffLayout,
1217    /// Available terminal columns.
1218    pub width: usize,
1219    /// Maximum rows, including an omission marker.
1220    pub max_rows: usize,
1221    /// Whether source bodies hard-wrap at display width.
1222    pub wrap: bool,
1223    /// Side-by-side fallback threshold.
1224    pub min_side_by_side_width: usize,
1225}
1226
1227impl Default for LayoutOptions {
1228    fn default() -> Self {
1229        Self {
1230            layout: DiffLayout::Unified,
1231            width: 80,
1232            max_rows: 2_000,
1233            wrap: true,
1234            min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
1235        }
1236    }
1237}
1238
1239/// Semantic role for a renderer-neutral row.
1240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1241pub enum DiffRowKind {
1242    /// File metadata outside a hunk.
1243    Metadata,
1244    /// Hunk header.
1245    HunkHeader,
1246    /// Unchanged content.
1247    Context,
1248    /// Added content.
1249    Addition,
1250    /// Deleted content.
1251    Deletion,
1252    /// A bounded middle omission.
1253    Omission,
1254}
1255
1256/// One independently styled content segment.
1257#[derive(Debug, Clone, PartialEq, Eq)]
1258pub struct DiffSegment {
1259    /// Segment text.
1260    pub text: String,
1261    /// Whether the segment receives intraline emphasis.
1262    pub emphasized: bool,
1263}
1264
1265/// One side of a semantic preview row.
1266#[derive(Debug, Clone, PartialEq, Eq)]
1267pub struct DiffCell {
1268    /// Old-side line number.
1269    pub old_line: Option<u32>,
1270    /// New-side line number.
1271    pub new_line: Option<u32>,
1272    /// Diff marker.
1273    pub marker: char,
1274    /// Styled content segments.
1275    pub segments: Vec<DiffSegment>,
1276}
1277
1278/// A renderer-neutral row, optionally containing paired side-by-side cells.
1279#[derive(Debug, Clone, PartialEq, Eq)]
1280pub struct DiffRow {
1281    /// Semantic role.
1282    pub kind: DiffRowKind,
1283    /// Marker for unified renderers and simple inspection.
1284    pub marker: char,
1285    /// Stable zero-based hunk identity.
1286    pub hunk_index: Option<usize>,
1287    /// Whether this row continues a hard-wrapped source line.
1288    pub continuation: bool,
1289    /// Unified or old-side cell.
1290    pub left: Option<DiffCell>,
1291    /// New-side cell in side-by-side mode.
1292    pub right: Option<DiffCell>,
1293}
1294
1295fn layout_document(document: &DiffDocument, options: LayoutOptions) -> Vec<DiffRow> {
1296    let display = display_lines_from_hunks_with_timeout(&document.hunks, document.inline_timeout);
1297    layout_display_lines(&display, options)
1298}
1299
1300/// Lays out precomputed display lines without repeating intraline analysis.
1301///
1302/// Interactive applications can cache semantic lines when an overlay opens
1303/// and call this inexpensive step again after a resize.
1304#[must_use]
1305pub fn layout_display_lines(display: &[DiffDisplayLine], options: LayoutOptions) -> Vec<DiffRow> {
1306    let use_side_by_side = options.layout == DiffLayout::SideBySide && options.width >= options.min_side_by_side_width;
1307    let mut rows = RowCollector::new(options.max_rows);
1308    if use_side_by_side {
1309        layout_side_by_side(display, options, &mut rows);
1310    } else {
1311        layout_unified(display, options, &mut rows);
1312    }
1313    rows.finish()
1314}
1315
1316/// Returns a bounded head/tail excerpt of semantic display lines.
1317///
1318/// The returned vector contains at most `max_rows` entries. When rows are
1319/// omitted, one metadata entry (`... N lines omitted ...`) is inserted between
1320/// the retained head and tail. Source line numbers and intraline ranges on
1321/// retained entries are preserved, so callers can render the excerpt without
1322/// reparsing or inventing positions.
1323#[must_use]
1324pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
1325    if max_rows == 0 {
1326        return Vec::new();
1327    }
1328    if lines.len() <= max_rows {
1329        return lines.to_vec();
1330    }
1331
1332    let retained = max_rows.saturating_sub(1);
1333    let head_count = retained.saturating_add(1) / 2;
1334    let tail_count = retained / 2;
1335    let omitted = lines.len().saturating_sub(head_count + tail_count);
1336
1337    let mut bounded = Vec::with_capacity(max_rows);
1338    bounded.extend_from_slice(&lines[..head_count]);
1339    bounded.push(DiffDisplayLine::body(
1340        DiffDisplayKind::Metadata,
1341        None,
1342        None,
1343        format!("... {omitted} lines omitted ..."),
1344    ));
1345    if tail_count > 0 {
1346        bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
1347    }
1348    bounded
1349}
1350
1351fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1352    let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
1353    let content_width = options.width.saturating_sub(gutter_width).max(1);
1354    let mut hunk_index = None;
1355    for line in lines {
1356        if line.kind == DiffDisplayKind::Metadata {
1357            rows.push(metadata_row(&line.text, hunk_index));
1358            continue;
1359        }
1360        if line.kind == DiffDisplayKind::HunkHeader {
1361            hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1362            rows.push(header_row(&line.text, hunk_index));
1363            continue;
1364        }
1365        let marker = marker_for_kind(line.kind);
1366        for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
1367            .into_iter()
1368            .enumerate()
1369        {
1370            rows.push(DiffRow {
1371                kind: row_kind(line.kind),
1372                marker,
1373                hunk_index,
1374                continuation: continuation > 0,
1375                left: Some(DiffCell {
1376                    old_line: (continuation == 0).then_some(line.old_line).flatten(),
1377                    new_line: (continuation == 0).then_some(line.new_line).flatten(),
1378                    marker,
1379                    segments,
1380                }),
1381                right: None,
1382            });
1383        }
1384    }
1385}
1386
1387fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1388    let pane_width = options.width.saturating_sub(1) / 2;
1389    let content_width = pane_width.saturating_sub(6).max(1);
1390    let mut hunk_index = None;
1391    for_each_side_by_side_pair(lines, |left, right| {
1392        let is_full_width = right.is_none()
1393            && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
1394        if is_full_width {
1395            let text = left.map_or("", |line| line.text.as_str());
1396            if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
1397                hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1398                rows.push(header_row(text, hunk_index));
1399            } else {
1400                rows.push(metadata_row(text, hunk_index));
1401            }
1402            return;
1403        }
1404        let left_parts = left.map_or_else(
1405            || vec![Vec::new()],
1406            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1407        );
1408        let right_parts = right.map_or_else(
1409            || vec![Vec::new()],
1410            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1411        );
1412        let count = left_parts.len().max(right_parts.len());
1413        for offset in 0..count {
1414            let left_cell = left.and_then(|line| {
1415                left_parts.get(offset).map(|segments| DiffCell {
1416                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1417                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1418                    marker: marker_for_kind(line.kind),
1419                    segments: segments.clone(),
1420                })
1421            });
1422            let right_cell = right.and_then(|line| {
1423                right_parts.get(offset).map(|segments| DiffCell {
1424                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1425                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1426                    marker: marker_for_kind(line.kind),
1427                    segments: segments.clone(),
1428                })
1429            });
1430            let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
1431            rows.push(DiffRow {
1432                kind,
1433                marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
1434                hunk_index,
1435                continuation: offset > 0,
1436                left: left_cell,
1437                right: right_cell,
1438            });
1439        }
1440    });
1441}
1442
1443fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1444    DiffRow {
1445        kind: DiffRowKind::HunkHeader,
1446        marker: '@',
1447        hunk_index,
1448        continuation: false,
1449        left: Some(DiffCell {
1450            old_line: None,
1451            new_line: None,
1452            marker: '@',
1453            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1454        }),
1455        right: None,
1456    }
1457}
1458
1459fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1460    DiffRow {
1461        kind: DiffRowKind::Metadata,
1462        marker: ' ',
1463        hunk_index,
1464        continuation: false,
1465        left: Some(DiffCell {
1466            old_line: None,
1467            new_line: None,
1468            marker: ' ',
1469            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1470        }),
1471        right: None,
1472    }
1473}
1474
1475struct RowCollector {
1476    head: Vec<DiffRow>,
1477    tail: VecDeque<DiffRow>,
1478    max_rows: usize,
1479    total_rows: usize,
1480    overflowed: bool,
1481}
1482
1483impl RowCollector {
1484    fn new(max_rows: usize) -> Self {
1485        Self {
1486            head: Vec::new(),
1487            tail: VecDeque::new(),
1488            max_rows,
1489            total_rows: 0,
1490            overflowed: false,
1491        }
1492    }
1493
1494    fn push(&mut self, row: DiffRow) {
1495        self.total_rows = self.total_rows.saturating_add(1);
1496        if self.max_rows == 0 {
1497            return;
1498        }
1499        if !self.overflowed {
1500            self.head.push(row);
1501            if self.head.len() <= self.max_rows {
1502                return;
1503            }
1504
1505            let retained = self.max_rows.saturating_sub(1);
1506            let head_count = retained.saturating_add(1) / 2;
1507            let tail_count = retained / 2;
1508            if tail_count > 0 {
1509                let tail_start = self.head.len() - tail_count;
1510                self.tail = self.head.split_off(tail_start).into();
1511            }
1512            self.head.truncate(head_count);
1513            self.overflowed = true;
1514            return;
1515        }
1516
1517        let tail_count = self.max_rows.saturating_sub(1) / 2;
1518        if tail_count > 0 {
1519            if self.tail.len() == tail_count {
1520                let _ = self.tail.pop_front();
1521            }
1522            self.tail.push_back(row);
1523        }
1524    }
1525
1526    fn finish(mut self) -> Vec<DiffRow> {
1527        if !self.overflowed {
1528            return self.head;
1529        }
1530        let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
1531        self.head.push(omission_row(omitted));
1532        self.head.extend(self.tail);
1533        self.head
1534    }
1535}
1536
1537fn omission_row(omitted: usize) -> DiffRow {
1538    DiffRow {
1539        kind: DiffRowKind::Omission,
1540        marker: '…',
1541        hunk_index: None,
1542        continuation: false,
1543        left: Some(DiffCell {
1544            old_line: None,
1545            new_line: None,
1546            marker: '…',
1547            segments: vec![DiffSegment {
1548                text: format!("{omitted} rows omitted"),
1549                emphasized: false,
1550            }],
1551        }),
1552        right: None,
1553    }
1554}
1555
1556fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
1557    if !wrap || UnicodeWidthStr::width(text) <= width {
1558        return vec![segment_slice(text, changed, 0, text.len())];
1559    }
1560    let mut rows = Vec::new();
1561    let mut byte_start = 0usize;
1562    let mut display_width = 0usize;
1563    for (byte, character) in text.char_indices() {
1564        let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
1565        if display_width > 0 && display_width.saturating_add(char_width) > width {
1566            rows.push(segment_slice(text, changed, byte_start, byte));
1567            byte_start = byte;
1568            display_width = 0;
1569        }
1570        display_width = display_width.saturating_add(char_width);
1571    }
1572    rows.push(segment_slice(text, changed, byte_start, text.len()));
1573    rows
1574}
1575
1576fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
1577    if start == end {
1578        return vec![DiffSegment { text: String::new(), emphasized: false }];
1579    }
1580    let mut boundaries = vec![start, end];
1581    for &(range_start, range_end) in changed {
1582        if range_start < end && range_end > start {
1583            let bounded_start = range_start.max(start).min(end);
1584            let bounded_end = range_end.max(start).min(end);
1585            if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
1586                boundaries.push(bounded_start);
1587                boundaries.push(bounded_end);
1588            }
1589        }
1590    }
1591    boundaries.sort_unstable();
1592    boundaries.dedup();
1593    boundaries
1594        .windows(2)
1595        .filter_map(|pair| {
1596            let segment_start = pair[0];
1597            let segment_end = pair[1];
1598            (segment_start < segment_end).then(|| DiffSegment {
1599                text: text[segment_start..segment_end].to_owned(),
1600                emphasized: changed
1601                    .iter()
1602                    .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
1603            })
1604        })
1605        .collect()
1606}
1607
1608fn marker_for_kind(kind: DiffDisplayKind) -> char {
1609    match kind {
1610        DiffDisplayKind::Addition => '+',
1611        DiffDisplayKind::Deletion => '-',
1612        DiffDisplayKind::HunkHeader => '@',
1613        DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
1614    }
1615}
1616
1617fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
1618    match kind {
1619        DiffDisplayKind::Addition => DiffRowKind::Addition,
1620        DiffDisplayKind::Deletion => DiffRowKind::Deletion,
1621        DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
1622        DiffDisplayKind::Metadata => DiffRowKind::Metadata,
1623        DiffDisplayKind::Context => DiffRowKind::Context,
1624    }
1625}
1626
1627fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
1628    let (old, _, new, _) = parse_hunk_range(line)?;
1629    Some((old, new))
1630}
1631
1632fn parse_hunk_range(line: &str) -> Option<(u32, usize, u32, usize)> {
1633    let body = line.strip_prefix("@@ ")?.split(" @@").next()?;
1634    let mut parts = body.split_whitespace();
1635    let (old_start, old_count) = parse_range(parts.next()?, '-')?;
1636    let (new_start, new_count) = parse_range(parts.next()?, '+')?;
1637    Some((old_start, old_count, new_start, new_count))
1638}
1639
1640fn parse_range(value: &str, marker: char) -> Option<(u32, usize)> {
1641    let range = value.strip_prefix(marker)?;
1642    let mut parts = range.splitn(2, ',');
1643    let start = parts.next()?.parse().ok()?;
1644    let count = parts.next().map_or(Some(1), |count| count.parse().ok())?;
1645    Some((start, count))
1646}
1647
1648fn parse_omitted_line_count(line: &str) -> Option<usize> {
1649    let line = line.trim();
1650    line.strip_prefix("... ")?.strip_suffix(" lines omitted ...")?.parse().ok()
1651}
1652
1653fn is_unified_metadata_line(line: &str) -> bool {
1654    line.starts_with("--- ")
1655        || line.starts_with("+++ ")
1656        || line.starts_with("new file mode ")
1657        || line.starts_with("deleted file mode ")
1658        || line.starts_with("rename from ")
1659        || line.starts_with("rename to ")
1660        || line.starts_with("copy from ")
1661        || line.starts_with("copy to ")
1662        || line.starts_with("similarity index ")
1663        || line.starts_with("dissimilarity index ")
1664        || line.starts_with("old mode ")
1665        || line.starts_with("new mode ")
1666        || line.starts_with("Binary files ")
1667        || line == "GIT binary patch"
1668        || line.starts_with("literal ")
1669        || line.starts_with("delta ")
1670}
1671
1672fn trim_line_ending(text: &str) -> &str {
1673    text.strip_suffix("\r\n")
1674        .or_else(|| text.strip_suffix('\n'))
1675        .or_else(|| text.strip_suffix('\r'))
1676        .unwrap_or(text)
1677}
1678
1679#[cfg(feature = "ansi")]
1680mod ansi_adapter {
1681    use super::DiffRow;
1682    use anstyle::{Reset, Style};
1683
1684    /// Caller-supplied foreground styles for ANSI output.
1685    #[derive(Debug, Clone, Copy, Default)]
1686    pub struct AnsiDiffPalette {
1687        /// Hunk and omission style.
1688        pub header: Style,
1689        /// Context style.
1690        pub context: Style,
1691        /// Addition style.
1692        pub addition: Style,
1693        /// Deletion style.
1694        pub deletion: Style,
1695        /// Extra intraline emphasis.
1696        pub emphasis: Style,
1697    }
1698
1699    /// Renders semantic rows as ANSI lines.
1700    #[must_use]
1701    pub fn render_ansi_rows(rows: &[DiffRow], palette: AnsiDiffPalette, color: bool) -> Vec<String> {
1702        rows.iter()
1703            .map(|row| {
1704                let mut output = String::new();
1705                output.push(row.marker);
1706                output.push(' ');
1707                if let Some(cell) = &row.left {
1708                    render_cell(&mut output, cell, palette, color);
1709                }
1710                if let Some(cell) = &row.right {
1711                    output.push_str(" │ ");
1712                    render_cell(&mut output, cell, palette, color);
1713                }
1714                output
1715            })
1716            .collect()
1717    }
1718
1719    fn render_cell(output: &mut String, cell: &super::DiffCell, palette: AnsiDiffPalette, color: bool) {
1720        let style = match cell.marker {
1721            '+' => palette.addition,
1722            '-' => palette.deletion,
1723            '@' | '…' => palette.header,
1724            _ => palette.context,
1725        };
1726        for segment in &cell.segments {
1727            if color {
1728                let selected = if segment.emphasized { palette.emphasis } else { style };
1729                output.push_str(&selected.render().to_string());
1730                output.push_str(&segment.text);
1731                output.push_str(&Reset.render().to_string());
1732            } else {
1733                output.push_str(&segment.text);
1734            }
1735        }
1736    }
1737}
1738
1739#[cfg(feature = "ansi")]
1740pub use ansi_adapter::{AnsiDiffPalette, render_ansi_rows};
1741
1742#[cfg(feature = "ratatui")]
1743mod ratatui_adapter {
1744    use super::DiffRow;
1745    use ratatui::text::{Line, Span};
1746
1747    /// Converts semantic rows to unstyled Ratatui lines for caller styling.
1748    #[must_use]
1749    pub fn to_ratatui_lines(rows: &[DiffRow]) -> Vec<Line<'static>> {
1750        rows.iter()
1751            .map(|row| {
1752                let mut spans = vec![Span::raw(format!("{} ", row.marker))];
1753                if let Some(cell) = &row.left {
1754                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1755                }
1756                if let Some(cell) = &row.right {
1757                    spans.push(Span::raw(" │ "));
1758                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1759                }
1760                Line::from(spans)
1761            })
1762            .collect()
1763    }
1764}
1765
1766#[cfg(feature = "ratatui")]
1767pub use ratatui_adapter::to_ratatui_lines;
1768
1769#[cfg(test)]
1770mod tests {
1771    use super::*;
1772
1773    #[test]
1774    fn asymmetric_replacement_has_correct_line_numbers() {
1775        let document = DiffDocument::between("a\nb\nc\n", "a\nx\ny\nc\n", DiffOptions::default());
1776        let lines: Vec<_> = document.hunks.iter().flat_map(|hunk| &hunk.lines).collect();
1777        assert!(lines.iter().any(|line| {
1778            line.kind == DiffLineKind::Deletion && line.old_line == Some(2) && line.new_line.is_none()
1779        }));
1780        assert!(lines.iter().any(|line| {
1781            line.kind == DiffLineKind::Addition && line.old_line.is_none() && line.new_line == Some(3)
1782        }));
1783        assert_eq!(document.stats.additions, 2);
1784        assert_eq!(document.stats.deletions, 1);
1785    }
1786
1787    #[test]
1788    fn repeated_lines_keep_the_changed_anchor() {
1789        let document = DiffDocument::between("same\nold\nsame\n", "same\nnew\nsame\n", DiffOptions::default());
1790        assert_eq!(document.stats.additions, 1);
1791        assert_eq!(document.stats.deletions, 1);
1792        assert_eq!(document.hunks[0].old_start, 1);
1793    }
1794
1795    #[test]
1796    fn zero_context_hunks_preserve_empty_side_anchors() {
1797        let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
1798        let insertion = DiffDocument::between("a\nc\n", "a\nb\nc\n", options.clone());
1799        assert_eq!(insertion.hunks[0].old_start, 2);
1800        assert_eq!(insertion.hunks[0].new_start, 2);
1801
1802        let deletion = DiffDocument::between("a\nb\nc\n", "a\nc\n", options);
1803        assert_eq!(deletion.hunks[0].old_start, 2);
1804        assert_eq!(deletion.hunks[0].new_start, 2);
1805    }
1806
1807    #[test]
1808    fn preserves_crlf_cr_and_missing_final_newline() {
1809        let crlf = DiffDocument::between("a\r\nb\r\n", "a\r\nx\r\n", DiffOptions::default());
1810        assert!(crlf.hunks[0].lines.iter().any(|line| line.text == "a\r\n"));
1811        let cr = DiffDocument::between("a\rb\r", "a\rx\r", DiffOptions::default());
1812        assert!(cr.hunks[0].lines.iter().any(|line| line.text == "a\r"));
1813        let eof = DiffDocument::between("a\n", "a", DiffOptions::default());
1814        assert_eq!(eof.stats.additions, 1);
1815        assert_eq!(eof.stats.deletions, 1);
1816    }
1817
1818    #[test]
1819    fn parser_rejects_body_before_hunk() {
1820        let error = DiffDocument::from_unified("-old\n+new\n").expect_err("body must need a hunk");
1821        assert_eq!(error.to_string(), "diff body appears before a hunk header");
1822    }
1823
1824    #[test]
1825    fn parser_accepts_standard_git_metadata_before_hunk() {
1826        let document = DiffDocument::from_unified(
1827            "diff --git a/file.txt b/file.txt\nindex 1111111..2222222 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n",
1828        )
1829        .expect("standard git metadata is not a body");
1830        assert_eq!(document.stats.deletions, 1);
1831        assert_eq!(document.stats.additions, 1);
1832    }
1833
1834    #[test]
1835    fn parser_accepts_metadata_between_multiple_file_hunks() {
1836        let document = DiffDocument::from_unified(
1837            "diff --git a/one.txt b/one.txt\nnew file mode 100644\n--- /dev/null\n+++ b/one.txt\n@@ -0,0 +1 @@\n+one\ndiff --git a/two.txt b/two.txt\nnew file mode 100644\n--- /dev/null\n+++ b/two.txt\n@@ -0,0 +1 @@\n+two\n",
1838        )
1839        .expect("metadata between files is not a hunk body");
1840        assert_eq!(document.hunks.len(), 2);
1841        assert_eq!(document.stats.additions, 2);
1842    }
1843
1844    #[test]
1845    fn parsed_body_lines_preserve_original_terminators() {
1846        let document = DiffDocument::from_unified("@@ -1 +1 @@\r\n-old\r\n+new\r\n").expect("valid CRLF diff");
1847        assert_eq!(document.hunks[0].lines[0].text, "old\r\n");
1848        assert_eq!(document.hunks[0].lines[1].text, "new\r\n");
1849
1850        let document = DiffDocument::from_unified("@@ -1 +1 @@\r-old\r+new\r").expect("valid CR diff");
1851        assert_eq!(document.hunks[0].lines[0].text, "old\r");
1852        assert_eq!(document.hunks[0].lines[1].text, "new\r");
1853    }
1854
1855    #[test]
1856    fn parser_rejects_incomplete_hunk_body() {
1857        let error = DiffDocument::from_unified("@@ -1,2 +1,2 @@\n-old\n+new\n")
1858            .expect_err("hunk body must satisfy the declared ranges");
1859        assert_eq!(error.to_string(), "hunk line counts do not match header");
1860    }
1861
1862    #[test]
1863    fn parser_rejects_unknown_backslash_metadata_inside_hunk() {
1864        let error = DiffDocument::from_unified("@@ -1 +1 @@\n-old\n\\ unexpected marker\n+new\n")
1865            .expect_err("only Git's no-newline marker is valid inside a hunk");
1866        assert_eq!(error.to_string(), "invalid unified diff body line");
1867    }
1868
1869    #[test]
1870    fn parser_tracks_asymmetric_hunk_numbers() {
1871        let document = DiffDocument::from_unified("@@ -4,1 +8,2 @@\n-old\n+new\n+extra\n").expect("valid diff");
1872        assert_eq!(document.hunks[0].old_start, 4);
1873        assert_eq!(document.hunks[0].new_start, 8);
1874        assert_eq!(document.hunks[0].lines[2].new_line, Some(9));
1875    }
1876
1877    #[test]
1878    fn parser_keeps_context_lines_that_look_like_omission_markers() {
1879        let document =
1880            DiffDocument::from_unified("@@ -1 +1 @@\n ... 4 lines omitted ...\n").expect("valid context line");
1881        assert_eq!(document.hunks[0].lines[0].kind, DiffLineKind::Context);
1882        assert_eq!(document.stats.omitted_rows, 0);
1883    }
1884
1885    #[test]
1886    fn parsed_omission_advances_both_line_counters() {
1887        let lines = display_lines_from_unified_diff("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n");
1888        let deletion = lines
1889            .iter()
1890            .find(|line| line.kind == DiffDisplayKind::Deletion)
1891            .expect("deletion after omission");
1892        let addition = lines
1893            .iter()
1894            .find(|line| line.kind == DiffDisplayKind::Addition)
1895            .expect("addition after omission");
1896        assert_eq!(deletion.old_line, Some(15));
1897        assert_eq!(addition.new_line, Some(25));
1898    }
1899
1900    #[test]
1901    fn truncated_hunk_keeps_tail_additions_as_diff_lines() {
1902        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1903        for index in 0..95 {
1904            input.push_str(&format!("-old-{index}\n"));
1905        }
1906        input.push_str("... 243 lines omitted ...\n");
1907        for index in 137..201 {
1908            input.push_str(&format!("+new-{index}\n"));
1909        }
1910
1911        let lines = display_lines_from_unified_diff(&input);
1912        let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1913        assert_eq!(additions.len(), 64);
1914        assert_eq!(additions.last().map(|line| line.text.as_str()), Some("new-200"));
1915    }
1916
1917    #[test]
1918    fn truncated_hunk_numbers_tail_from_declared_end() {
1919        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1920        for index in 0..93 {
1921            input.push_str(&format!("-old-{index}\n"));
1922        }
1923        input.push_str("... 277 lines omitted ...\n");
1924        for index in 169..201 {
1925            input.push_str(&format!("+new-{index}\n"));
1926        }
1927
1928        let lines = display_lines_from_unified_diff(&input);
1929        let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1930        assert_eq!(additions.first().and_then(|line| line.new_line), Some(170));
1931        assert_eq!(additions.last().and_then(|line| line.new_line), Some(201));
1932    }
1933
1934    #[test]
1935    fn parsed_metadata_stays_outside_hunk_semantics() {
1936        let display = display_lines_from_unified_diff("--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n");
1937        let rows = layout_display_lines(&display, LayoutOptions::default());
1938        assert_eq!(rows[0].kind, DiffRowKind::Metadata);
1939        assert_eq!(rows[1].kind, DiffRowKind::Metadata);
1940        assert_eq!(rows[2].kind, DiffRowKind::HunkHeader);
1941        assert_eq!(rows[2].hunk_index, Some(0));
1942    }
1943
1944    #[test]
1945    fn unified_parser_accepts_omission_and_advances_numbers() {
1946        let document = DiffDocument::from_unified("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n")
1947            .expect("omission marker is valid bounded preview metadata");
1948        assert_eq!(document.hunks[0].lines[1].old_line, Some(15));
1949        assert_eq!(document.hunks[0].lines[2].new_line, Some(25));
1950        assert_eq!(document.hunks[0].old_lines, 6);
1951        assert_eq!(document.hunks[0].new_lines, 6);
1952        assert_eq!(document.stats.omitted_rows, 4);
1953    }
1954
1955    #[test]
1956    fn unified_parser_accepts_asymmetric_bounded_omission() {
1957        let document =
1958            DiffDocument::from_unified("@@ -1,4 +1,8 @@\n-old-1\n... 4 lines omitted ...\n+new-6\n+new-7\n+new-8\n")
1959                .expect("bounded omission may hide different old/new line counts");
1960        assert_eq!(document.stats.omitted_rows, 4);
1961        assert_eq!(document.hunks[0].old_lines, 4);
1962        assert_eq!(document.hunks[0].new_lines, 6);
1963    }
1964
1965    #[test]
1966    fn unified_parser_numbers_one_sided_omitted_tails_from_hunk_end() {
1967        let added = DiffDocument::from_unified("@@ -0,0 +1,5 @@\n+one\n... 3 lines omitted ...\n+five\n")
1968            .expect("bounded addition is valid");
1969        assert_eq!(added.hunks[0].lines[1].new_line, Some(5));
1970
1971        let deleted = DiffDocument::from_unified("@@ -1,5 +0,0 @@\n-one\n... 3 lines omitted ...\n-five\n")
1972            .expect("bounded deletion is valid");
1973        assert_eq!(deleted.hunks[0].lines[1].old_line, Some(5));
1974    }
1975
1976    #[test]
1977    fn intraline_ranges_are_utf8_boundaries() {
1978        let (old, new) = word_level_changed_ranges("café rouge", "café bleu");
1979        for (start, end) in old {
1980            assert!("café rouge".is_char_boundary(start));
1981            assert!("café rouge".is_char_boundary(end));
1982        }
1983        for (start, end) in new {
1984            assert!("café bleu".is_char_boundary(start));
1985            assert!("café bleu".is_char_boundary(end));
1986        }
1987    }
1988
1989    #[test]
1990    fn unicode_width_wrap_keeps_numbers_only_on_first_row() {
1991        let document = DiffDocument::between("", "界界界a\n", DiffOptions::default());
1992        let rows = document.layout(LayoutOptions { width: 12, ..LayoutOptions::default() });
1993        let additions: Vec<_> = rows.iter().filter(|row| row.kind == DiffRowKind::Addition).collect();
1994        assert!(additions.len() >= 2);
1995        assert_eq!(additions[0].left.as_ref().and_then(|cell| cell.new_line), Some(1));
1996        assert!(additions[1].continuation);
1997        assert_eq!(additions[1].left.as_ref().and_then(|cell| cell.new_line), None);
1998        assert_eq!(additions[1].marker, '+');
1999    }
2000
2001    #[test]
2002    fn layout_ignores_invalid_intraline_boundaries() {
2003        let display = [DiffDisplayLine {
2004            kind: DiffDisplayKind::Addition,
2005            old_line: None,
2006            new_line: Some(1),
2007            text: "café".to_owned(),
2008            changed: vec![(2, 4)],
2009        }];
2010        let rows = layout_display_lines(&display, LayoutOptions { width: 20, ..LayoutOptions::default() });
2011        assert_eq!(rows.len(), 1);
2012        assert_eq!(rows[0].left.as_ref().expect("cell").segments[0].text, "café");
2013    }
2014
2015    #[test]
2016    fn narrow_side_by_side_falls_back_to_unified() {
2017        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2018        let rows = document.layout(LayoutOptions {
2019            layout: DiffLayout::SideBySide,
2020            width: 40,
2021            ..LayoutOptions::default()
2022        });
2023        assert!(
2024            rows.iter()
2025                .filter(|row| row.kind != DiffRowKind::HunkHeader)
2026                .all(|row| row.right.is_none())
2027        );
2028    }
2029
2030    #[test]
2031    fn responsive_gutter_policy_preserves_source_room_at_the_boundary() {
2032        assert_eq!(diff_gutter_width(5), 9);
2033        assert!(diff_gutter_fits(29, 5));
2034        assert!(!diff_gutter_fits(28, 5));
2035        assert_eq!(diff_layout_width(29, 5, true), 29);
2036        assert_eq!(diff_layout_width(28, 5, false), 37);
2037    }
2038
2039    #[test]
2040    fn side_by_side_policy_has_a_stable_resize_boundary() {
2041        assert!(!diff_side_by_side_fits(Some(59)));
2042        assert!(diff_side_by_side_fits(Some(DIFF_MIN_SIDE_BY_SIDE_WIDTH)));
2043        assert!(diff_side_by_side_fits(None));
2044    }
2045
2046    #[test]
2047    fn side_by_side_wrapping_leaves_shorter_side_empty() {
2048        let document =
2049            DiffDocument::between("short\n", "this is a much longer replacement line\n", DiffOptions::default());
2050        let rows = document.layout(LayoutOptions {
2051            layout: DiffLayout::SideBySide,
2052            width: 80,
2053            ..LayoutOptions::default()
2054        });
2055        let continuation = rows.iter().find(|row| row.continuation).expect("long replacement should wrap");
2056        assert!(continuation.left.is_none());
2057        assert!(continuation.right.is_some());
2058    }
2059
2060    #[test]
2061    fn bounded_rows_keep_head_tail_and_exact_omission() {
2062        let old = (0..20).map(|index| format!("old-{index}\n")).collect::<String>();
2063        let new = (0..20).map(|index| format!("new-{index}\n")).collect::<String>();
2064        let rows = DiffDocument::between(&old, &new, DiffOptions::default())
2065            .layout(LayoutOptions { max_rows: 5, ..LayoutOptions::default() });
2066        assert_eq!(rows.len(), 5);
2067        let omission = rows.iter().find(|row| row.kind == DiffRowKind::Omission).expect("omission row");
2068        let text = &omission.left.as_ref().expect("cell").segments[0].text;
2069        assert!(text.ends_with(" rows omitted"));
2070        assert!(rows.last().and_then(|row| row.left.as_ref()).is_some());
2071    }
2072
2073    #[test]
2074    fn bounded_display_lines_keep_asymmetric_head_and_tail() {
2075        let lines =
2076            display_lines_from_unified_diff("@@ -1,4 +1,4 @@\n-old-head\n+new-head\n context\n-old-tail\n+new-tail\n");
2077        let bounded = bounded_display_lines(&lines, 4);
2078
2079        assert_eq!(bounded.len(), 4);
2080        assert_eq!(bounded[0].text, "@@ -1 +1 @@");
2081        assert_eq!(bounded[1].text, "old-head");
2082        assert_eq!(bounded[2].kind, DiffDisplayKind::Metadata);
2083        assert!(bounded[2].text.contains("lines omitted"));
2084        assert_eq!(bounded[3].text, "new-tail");
2085    }
2086
2087    #[test]
2088    fn plain_unified_formatter_preserves_labels_and_newline_hints() {
2089        let output = format_unified_diff(
2090            "old\n",
2091            "new",
2092            DiffOptions {
2093                old_label: Some("a/file.txt"),
2094                new_label: Some("b/file.txt"),
2095                ..DiffOptions::default()
2096            },
2097        );
2098
2099        assert!(output.starts_with("--- a/file.txt\n+++ b/file.txt\n@@"));
2100        assert!(output.contains("@@ -1 +1 @@"));
2101        assert!(output.contains("-old\n"));
2102        assert!(output.contains("+new\n\\ No newline at end of file\n"));
2103        assert!(!output.contains('\r'));
2104    }
2105
2106    #[test]
2107    fn character_chunks_coalesce_and_reconstruct_both_sides() {
2108        let chunks = compute_diff_chunks("abc", "axc");
2109        assert_eq!(chunks.len(), 4);
2110        let old = chunks
2111            .iter()
2112            .filter_map(|chunk| match chunk {
2113                Chunk::Equal(text) | Chunk::Delete(text) => Some(*text),
2114                Chunk::Insert(_) => None,
2115            })
2116            .collect::<String>();
2117        let new = chunks
2118            .iter()
2119            .filter_map(|chunk| match chunk {
2120                Chunk::Equal(text) | Chunk::Insert(text) => Some(*text),
2121                Chunk::Delete(_) => None,
2122            })
2123            .collect::<String>();
2124        assert_eq!(old, "abc");
2125        assert_eq!(new, "axc");
2126    }
2127
2128    #[test]
2129    fn disjoint_large_input_respects_small_timeout_and_remains_readable() {
2130        let old = (0..10_000).map(|index| format!("old-{index}\n")).collect::<String>();
2131        let new = (0..10_000).rev().map(|index| format!("new-{index}\n")).collect::<String>();
2132        let started = Instant::now();
2133        let document = DiffDocument::between(
2134            &old,
2135            &new,
2136            DiffOptions {
2137                timeout: Duration::from_millis(5),
2138                ..DiffOptions::default()
2139            },
2140        );
2141        assert!(started.elapsed() < Duration::from_secs(2));
2142        assert!(document.stats.additions > 0);
2143        assert!(document.stats.deletions > 0);
2144    }
2145
2146    #[test]
2147    fn crlf_line_endings_are_preserved() {
2148        let document = DiffDocument::between("a\r\nb\r\n", "a\r\nc\r\n", DiffOptions::default());
2149        assert_eq!(document.hunks.len(), 1);
2150        let texts: Vec<&str> = document.hunks[0].lines.iter().map(|line| line.text.as_str()).collect();
2151        assert!(texts.contains(&"a\r\n"));
2152        assert!(texts.contains(&"b\r\n"));
2153        assert!(texts.contains(&"c\r\n"));
2154    }
2155
2156    #[test]
2157    fn missing_final_newline_emits_hint() {
2158        let document = DiffDocument::between("a\nb", "a\nb\n", DiffOptions::default());
2159        let formatted = format_unified_hunks(&document.hunks, &DiffOptions::default());
2160        assert!(formatted.contains("\\ No newline at end of file"));
2161    }
2162
2163    #[test]
2164    fn zero_context_insert_hunk_header_is_git_compatible() {
2165        let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
2166        let document = DiffDocument::between("", "x\ny\n", options.clone());
2167        let formatted = format_unified_hunks(&document.hunks, &options);
2168        assert!(formatted.contains("@@ -0,0 +1,2 @@"));
2169    }
2170
2171    #[test]
2172    fn binary_content_skips_intraline_annotation() {
2173        let binary_old = "data\u{0}one\nshared\n";
2174        let binary_new = "data\u{0}two\nshared\n";
2175        let lines =
2176            display_lines_from_hunks(&DiffDocument::between(binary_old, binary_new, DiffOptions::default()).hunks);
2177        let annotated: Vec<_> = lines.iter().filter(|line| !line.changed.is_empty()).collect();
2178        assert!(annotated.is_empty(), "binary lines must not receive intraline ranges");
2179
2180        // Sanity: text content still gets intraline ranges.
2181        let text_lines = display_lines_from_hunks(
2182            &DiffDocument::between("alpha beta\n", "alpha gamma\n", DiffOptions::default()).hunks,
2183        );
2184        assert!(text_lines.iter().any(|line| !line.changed.is_empty()));
2185    }
2186
2187    #[cfg(feature = "ansi")]
2188    #[test]
2189    fn ansi_adapter_can_render_without_color() {
2190        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2191        let rows = document.layout(LayoutOptions::default());
2192        let rendered = render_ansi_rows(&rows, AnsiDiffPalette::default(), false);
2193        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2194        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2195        assert!(rendered.iter().all(|line| !line.contains('\u{1b}')));
2196    }
2197
2198    #[cfg(feature = "ratatui")]
2199    #[test]
2200    fn ratatui_adapter_preserves_semantic_markers() {
2201        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2202        let rows = document.layout(LayoutOptions::default());
2203        let lines = to_ratatui_lines(&rows);
2204        let rendered = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
2205        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2206        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2207    }
2208}