Skip to main content

grit_lib/
structured_diff.rs

1//! Structured line diffs for CLI and embedders (`grit diff`, `grit show`).
2//!
3//! Preserves line-ending details (CRLF vs LF, missing newline at EOF), mode-only
4//! changes, hunk line counts, and optional UTF-8 lossy decoding markers — without
5//! relying on unified patch text parsing.
6
7use std::borrow::Cow;
8
9use similar::{ChangeTag, TextDiff};
10
11use crate::diff::options::DiffOptions;
12use crate::objects::FileMode;
13
14#[path = "diff/line_engine.rs"]
15mod line_engine;
16
17#[cfg(test)]
18pub(crate) use line_engine::structured_hunk_for_id_from_unified_slice;
19pub(crate) use line_engine::{
20    hash_hunk_body_from_unified_slice, hunk_body_digest_from_unified_slice,
21};
22
23/// Bytes of one file side prepared for diffing.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SideBytes {
26    /// Raw file contents.
27    pub data: Vec<u8>,
28    /// `true` when [`SideBytes::text`] used lossy UTF-8 decoding.
29    pub encoding_lossy: bool,
30    /// Byte length of this side; kept when [`Self::data`] is dropped for binary diffs.
31    pub size: u64,
32}
33
34impl SideBytes {
35    /// Decode as text for diffing (NUL in the first 8 KiB ⇒ treat as binary upstream).
36    #[must_use]
37    pub fn from_bytes(data: Vec<u8>) -> Self {
38        let size = data.len() as u64;
39        let encoding_lossy = !data.is_empty() && std::str::from_utf8(&data).is_err();
40        Self {
41            data,
42            encoding_lossy,
43            size,
44        }
45    }
46
47    /// Empty payload with a known on-disk byte length (binary stat / skipped reads).
48    #[must_use]
49    pub fn empty_with_size(size: u64) -> Self {
50        Self {
51            data: Vec::new(),
52            encoding_lossy: false,
53            size,
54        }
55    }
56
57    /// On-disk byte length of this side.
58    #[must_use]
59    pub fn byte_len(&self) -> u64 {
60        self.size
61    }
62
63    /// Lossy UTF-8 view of [`Self::data`].
64    #[must_use]
65    pub fn text(&self) -> String {
66        String::from_utf8_lossy(&self.data).into_owned()
67    }
68}
69
70/// One run of text within a line, with optional intra-line emphasis.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct StructuredSegment {
73    /// Visible characters for this run (may be a substring of the line).
74    pub text: String,
75    /// When `true`, render with intra-line emphasis (word-level diff).
76    pub emphasis: bool,
77}
78
79/// Kind of line in a structured hunk.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StructuredLineKind {
82    /// Unchanged context line present on both sides.
83    Context,
84    /// Line added on the new side.
85    Add,
86    /// Line removed from the old side.
87    Del,
88}
89
90/// One rendered line in a structured hunk.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StructuredLine {
93    /// Whether this line is context, an addition, or a deletion.
94    pub kind: StructuredLineKind,
95    /// 1-based line number on the old side, when applicable.
96    pub old: Option<usize>,
97    /// 1-based line number on the new side, when applicable.
98    pub new: Option<usize>,
99    /// Text runs that make up the rendered line.
100    pub segments: Vec<StructuredSegment>,
101    /// Git `\ No newline at end of file` on this side.
102    pub no_newline_at_eof: bool,
103}
104
105/// A contiguous run of changes plus surrounding context.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct StructuredHunk {
108    /// 1-based starting line on the old side (unified hunk header).
109    pub old_start: usize,
110    /// 1-based starting line on the new side.
111    pub new_start: usize,
112    /// Number of old-side lines in this hunk (including context).
113    pub old_lines: usize,
114    /// Number of new-side lines in this hunk (including context).
115    pub new_lines: usize,
116    /// Optional `@@` function/context header copied from Git patch text.
117    pub context: Option<String>,
118    /// Rendered lines in hunk order.
119    pub lines: Vec<StructuredLine>,
120}
121
122/// Line-level diff of two text sides (no file metadata).
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct StructuredTextDiff {
125    /// Line hunks between two text sides (no paths or modes).
126    pub hunks: Vec<StructuredHunk>,
127}
128
129/// File metadata plus a structured line diff.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct StructuredFileDiff {
132    /// Old git mode (octal string), when present.
133    pub old_mode: Option<String>,
134    /// New git mode (octal string), when present.
135    pub new_mode: Option<String>,
136    /// Old side required lossy UTF-8 decoding.
137    pub old_encoding_lossy: bool,
138    /// New side required lossy UTF-8 decoding.
139    pub new_encoding_lossy: bool,
140    /// Structured line hunks for this file pair.
141    pub hunks: Vec<StructuredHunk>,
142}
143
144/// Pre-split line bodies for O(1) lookup while building hunks.
145#[derive(Debug, Clone)]
146struct TextLines<'a> {
147    lines: Vec<&'a str>,
148}
149
150impl<'a> TextLines<'a> {
151    fn from_text(text: &'a str) -> Self {
152        let (parts, _) = lines_with_trailing_newline(text);
153        Self {
154            lines: parts.into_iter().map(strip_diff_newline_only).collect(),
155        }
156    }
157}
158
159/// Decode blob bytes for text diffing and patch-ID hashing (borrow when valid UTF-8).
160pub(crate) fn text_bytes_for_diff(data: &[u8]) -> Cow<'_, str> {
161    if data.is_empty() {
162        return Cow::Borrowed("");
163    }
164    match std::str::from_utf8(data) {
165        Ok(text) => Cow::Borrowed(text),
166        Err(_) => Cow::Owned(String::from_utf8_lossy(data).into_owned()),
167    }
168}
169
170/// Decode side bytes as UTF-8 when valid to avoid an extra allocation on the hot path.
171fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
172    text_bytes_for_diff(&side.data)
173}
174
175/// Like [`structured_text_diff`] but accepts borrowed text when sides are valid UTF-8.
176fn structured_text_diff_cow(
177    old_text: Cow<'_, str>,
178    new_text: Cow<'_, str>,
179    options: DiffOptions,
180) -> StructuredTextDiff {
181    structured_text_diff(old_text.as_ref(), new_text.as_ref(), options)
182}
183
184/// Build a structured line diff from two decoded text sides.
185#[must_use]
186pub fn structured_text_diff(
187    old_text: &str,
188    new_text: &str,
189    options: DiffOptions,
190) -> StructuredTextDiff {
191    let mut hunks = line_engine::line_hunks(old_text, new_text, &options, options.function_context);
192    if options.word_level_inline {
193        apply_word_level_inline(&mut hunks, old_text, new_text);
194    }
195    StructuredTextDiff { hunks }
196}
197
198fn apply_word_level_inline(hunks: &mut [StructuredHunk], old_text: &str, new_text: &str) {
199    let old = TextLines::from_text(old_text);
200    let new = TextLines::from_text(new_text);
201
202    for hunk in &mut *hunks {
203        let mut i = 0usize;
204        while i < hunk.lines.len() {
205            if hunk.lines[i].kind != StructuredLineKind::Del {
206                i += 1;
207                continue;
208            }
209            let del_start = i;
210            while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Del {
211                i += 1;
212            }
213            let add_start = i;
214            while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Add {
215                i += 1;
216            }
217            let del_end = i.min(add_start);
218            let add_end = i;
219            if del_end > del_start && add_end > add_start {
220                let del_count = del_end - del_start;
221                let add_count = add_end - add_start;
222                let pairs = del_count.min(add_count);
223                for p in 0..pairs {
224                    let del_idx = del_start + p;
225                    let add_idx = add_start + p;
226                    let (left, right) = hunk.lines.split_at_mut(add_idx);
227                    let del_line = &mut left[del_idx];
228                    let add_line = &mut right[0];
229                    emphasize_replace_line_pair(del_line, add_line);
230                }
231            }
232        }
233    }
234
235    pin_canonical_line_text(hunks, &old.lines, &new.lines);
236}
237
238fn emphasize_replace_line_pair(del: &mut StructuredLine, add: &mut StructuredLine) {
239    let old_line = del.segments[0].text.clone();
240    let new_line = add.segments[0].text.clone();
241    let diff = TextDiff::from_words(&old_line, &new_line);
242    del.segments = word_segments_from_diff(&diff, &old_line, true);
243    add.segments = word_segments_from_diff(&diff, &new_line, false);
244}
245
246fn word_segments_from_diff(
247    diff: &TextDiff<'_, '_, str>,
248    full_line: &str,
249    old_side: bool,
250) -> Vec<StructuredSegment> {
251    let mut segments: Vec<StructuredSegment> = Vec::new();
252    for change in diff.iter_all_changes() {
253        let is_this_side = match change.tag() {
254            ChangeTag::Delete => old_side,
255            ChangeTag::Insert => !old_side,
256            ChangeTag::Equal => true,
257        };
258        if !is_this_side {
259            continue;
260        }
261        let emphasis = change.tag() != ChangeTag::Equal;
262        let text = change.value().to_owned();
263        if text.is_empty() {
264            continue;
265        }
266        if let Some(last) = segments.last_mut() {
267            if last.emphasis == emphasis {
268                last.text.push_str(&text);
269                continue;
270            }
271        }
272        segments.push(StructuredSegment { text, emphasis });
273    }
274    if segments.is_empty() && !full_line.is_empty() {
275        segments.push(StructuredSegment {
276            text: full_line.to_owned(),
277            emphasis: false,
278        });
279    }
280    reconcile_segments_with_line(&mut segments, full_line);
281    segments
282}
283
284/// One O(n) pass: `similar` may drop `\r` in inline segments; restore from precomputed lines.
285fn pin_canonical_line_text(hunks: &mut [StructuredHunk], old_lines: &[&str], new_lines: &[&str]) {
286    for hunk in hunks {
287        for line in &mut hunk.lines {
288            let canonical = match line.kind {
289                StructuredLineKind::Del | StructuredLineKind::Context => line
290                    .old
291                    .and_then(|n| old_lines.get(n.saturating_sub(1)).copied()),
292                StructuredLineKind::Add => line
293                    .new
294                    .and_then(|n| new_lines.get(n.saturating_sub(1)).copied()),
295            };
296            let Some(canonical) = canonical else {
297                continue;
298            };
299            // Deleted lines always end up as exactly one segment.
300            let collapse = line.kind == StructuredLineKind::Del && line.segments.len() != 1;
301            if collapse || !segments_render_as(&line.segments, canonical) {
302                let emphasis = line.segments.iter().any(|s| s.emphasis);
303                line.segments = vec![StructuredSegment {
304                    text: canonical.to_owned(),
305                    emphasis,
306                }];
307            }
308        }
309    }
310}
311
312/// Whether the concatenated segment texts equal `text`, without allocating.
313fn segments_render_as(segments: &[StructuredSegment], text: &str) -> bool {
314    let mut rest = text;
315    for segment in segments {
316        match rest.strip_prefix(segment.text.as_str()) {
317            Some(tail) => rest = tail,
318            None => return false,
319        }
320    }
321    rest.is_empty()
322}
323
324/// Build a structured file diff including mode metadata from a tree diff entry.
325#[must_use]
326pub fn structured_file_diff(
327    old_mode: FileMode,
328    new_mode: FileMode,
329    old: &SideBytes,
330    new: &SideBytes,
331    options: DiffOptions,
332) -> StructuredFileDiff {
333    let old_mode = old_mode.octal_if_present();
334    let new_mode = new_mode.octal_if_present();
335    let old_text = side_text_for_diff(old);
336    let new_text = side_text_for_diff(new);
337    let hunks = structured_text_diff_cow(old_text, new_text, options).hunks;
338    StructuredFileDiff {
339        old_mode,
340        new_mode,
341        old_encoding_lossy: old.encoding_lossy,
342        new_encoding_lossy: new.encoding_lossy,
343        hunks,
344    }
345}
346
347/// Split on `\n` only so `\r` stays inside line bodies (Git CRLF parity).
348fn lines_with_trailing_newline(text: &str) -> (Vec<&str>, bool) {
349    if text.is_empty() {
350        return (Vec::new(), false);
351    }
352    let ends_nl = text.ends_with('\n');
353    let mut parts: Vec<&str> = text.split('\n').collect();
354    if ends_nl && parts.last() == Some(&"") {
355        parts.pop();
356    }
357    (parts, ends_nl)
358}
359
360fn strip_diff_newline_only(s: &str) -> &str {
361    s.strip_suffix('\n').unwrap_or(s)
362}
363
364/// Old-side line bodies for structured hunk metadata (CRLF-safe split).
365pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<&str> {
366    TextLines::from_text(text).lines
367}
368
369/// Nearest enclosing definition line above a 1-based old hunk start (Git `-W` style).
370pub(crate) fn enclosing_function_context(
371    lines: &[&str],
372    old_start_1based: usize,
373) -> Option<String> {
374    if old_start_1based == 0 {
375        return None;
376    }
377    function_context(lines, old_start_1based.saturating_sub(1))
378}
379
380fn function_context(lines: &[&str], start_0based: usize) -> Option<String> {
381    lines[..start_0based.min(lines.len())]
382        .iter()
383        .rev()
384        .find_map(|line| {
385            let first = line.chars().next()?;
386            (first.is_alphabetic() || first == '_').then(|| line.trim_end().to_owned())
387        })
388}
389
390fn reconcile_segments_with_line(segments: &mut Vec<StructuredSegment>, full_line: &str) {
391    if full_line.is_empty() {
392        return;
393    }
394    let rendered: String = segments.iter().map(|s| s.text.as_str()).collect();
395    if rendered != full_line {
396        let emphasis = segments.iter().any(|s| s.emphasis);
397        segments.clear();
398        segments.push(StructuredSegment {
399            text: full_line.to_owned(),
400            emphasis,
401        });
402    }
403}
404
405/// Format one unified hunk range side (`1`, `1,2`, or `0,0`).
406#[must_use]
407pub fn format_hunk_range(start: usize, count: usize) -> String {
408    if count == 0 {
409        "0,0".to_owned()
410    } else if count == 1 {
411        start.to_string()
412    } else {
413        format!("{start},{count}")
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::process::Command;
421
422    fn git_diff(old: &str, new: &str) -> String {
423        let tmp = tempfile::tempdir().expect("tempdir");
424        let path = tmp.path().join("f.txt");
425        std::fs::write(&path, old).expect("write old");
426        Command::new("git")
427            .args(["init", "-q", "-b", "main"])
428            .current_dir(tmp.path())
429            .status()
430            .expect("git init");
431        Command::new("git")
432            .args(["config", "user.email", "t@e.com"])
433            .current_dir(tmp.path())
434            .status()
435            .unwrap();
436        Command::new("git")
437            .args(["config", "user.name", "T"])
438            .current_dir(tmp.path())
439            .status()
440            .unwrap();
441        Command::new("git")
442            .args(["add", "f.txt"])
443            .current_dir(tmp.path())
444            .status()
445            .unwrap();
446        Command::new("git")
447            .args(["commit", "-qm", "init"])
448            .current_dir(tmp.path())
449            .status()
450            .unwrap();
451        std::fs::write(&path, new).expect("write new");
452        let out = Command::new("git")
453            .args(["diff", "f.txt"])
454            .current_dir(tmp.path())
455            .output()
456            .expect("git diff");
457        String::from_utf8(out.stdout).expect("utf8")
458    }
459
460    fn parse_hunk_header(patch: &str) -> (usize, usize, usize, usize) {
461        let line = patch.lines().find(|l| l.starts_with("@@")).expect("hunk");
462        let rest = line.strip_prefix("@@ ").expect("prefix");
463        let (old_part, new_part) = rest.split_once(" +").expect("plus");
464        let old_part = old_part.strip_prefix('-').expect("minus");
465        let (os, ol) = parse_range(old_part.split(' ').next().expect("old"));
466        let new_part = new_part.split(" @@").next().expect("close");
467        let (ns, nl) = parse_range(new_part);
468        (os, ol, ns, nl)
469    }
470
471    fn parse_range(s: &str) -> (usize, usize) {
472        if let Some((a, b)) = s.split_once(',') {
473            (a.parse().expect("start"), b.parse().expect("count"))
474        } else {
475            (s.parse().expect("start"), 1)
476        }
477    }
478
479    #[test]
480    fn lines_with_trailing_newline_splits_crlf() {
481        let (parts, ends) = lines_with_trailing_newline("a\r\nb\r\n");
482        assert!(ends);
483        assert_eq!(parts, ["a\r", "b\r"]);
484    }
485
486    #[test]
487    fn text_lines_from_text_preserves_cr() {
488        let t = TextLines::from_text("a\r\nb\r\n");
489        assert_eq!(t.lines[0], "a\r");
490    }
491
492    #[test]
493    fn crlf_to_lf_shows_carriage_return_on_removed_lines() {
494        let old = "a\r\nb\r\n";
495        let new = "a\nb\n";
496        let d = structured_text_diff(old, new, DiffOptions::default());
497        let del: Vec<_> = d.hunks[0]
498            .lines
499            .iter()
500            .filter(|l| l.kind == StructuredLineKind::Del)
501            .collect();
502        assert_eq!(del[0].segments[0].text, "a\r");
503    }
504
505    #[test]
506    fn trailing_newline_only_change_sets_no_newline_flag() {
507        let old = "line1\nline2";
508        let new = "line1\nline2\n";
509        let d = structured_text_diff(old, new, DiffOptions::default());
510        let del = d.hunks[0]
511            .lines
512            .iter()
513            .find(|l| l.kind == StructuredLineKind::Del)
514            .expect("deleted line2");
515        assert!(del.no_newline_at_eof);
516    }
517
518    #[test]
519    fn context_last_line_without_eof_newline_is_marked() {
520        let old = "old\nlast";
521        let new = "new\nlast";
522        let d = structured_text_diff(old, new, DiffOptions::default());
523        let ctx = d.hunks[0]
524            .lines
525            .iter()
526            .find(|l| l.kind == StructuredLineKind::Context && l.segments[0].text == "last")
527            .expect("context last");
528        assert!(ctx.no_newline_at_eof);
529    }
530
531    #[test]
532    fn segments_render_as_compares_concatenation() {
533        let seg = |text: &str| StructuredSegment {
534            text: text.into(),
535            emphasis: false,
536        };
537        assert!(segments_render_as(&[], ""));
538        assert!(!segments_render_as(&[], "a"));
539        assert!(segments_render_as(&[seg("ab"), seg("c")], "abc"));
540        assert!(!segments_render_as(&[seg("ab"), seg("c")], "abcd"));
541        assert!(!segments_render_as(&[seg("ab"), seg("x")], "abc"));
542        assert!(!segments_render_as(&[seg("abcd")], "abc"));
543    }
544
545    fn line_text(line: &StructuredLine) -> String {
546        line.segments.iter().map(|s| s.text.as_str()).collect()
547    }
548
549    #[test]
550    fn word_level_inline_keeps_canonical_line_text() {
551        let old = "keep\nfoo bar baz\n\nend\n";
552        let new = "keep\nfoo qux baz\nfilled\nend\n";
553        let plain = structured_text_diff(
554            old,
555            new,
556            DiffOptions::default().with_word_level_inline(false),
557        );
558        let inline = structured_text_diff(
559            old,
560            new,
561            DiffOptions::default().with_word_level_inline(true),
562        );
563        assert_eq!(plain.hunks.len(), inline.hunks.len());
564        for (p, i) in plain.hunks.iter().zip(&inline.hunks) {
565            assert_eq!(p.lines.len(), i.lines.len());
566            for (pl, il) in p.lines.iter().zip(&i.lines) {
567                assert_eq!((pl.kind, pl.old, pl.new), (il.kind, il.old, il.new));
568                assert_eq!(line_text(pl), line_text(il));
569                if il.kind == StructuredLineKind::Del {
570                    assert_eq!(
571                        il.segments.len(),
572                        1,
573                        "deleted lines collapse to one segment"
574                    );
575                }
576            }
577        }
578        let dels: Vec<_> = inline.hunks[0]
579            .lines
580            .iter()
581            .filter(|l| l.kind == StructuredLineKind::Del)
582            .collect();
583        assert_eq!(line_text(dels[0]), "foo bar baz");
584        assert!(dels[0].segments[0].emphasis);
585        assert_eq!(line_text(dels[1]), "");
586        let add = inline.hunks[0]
587            .lines
588            .iter()
589            .find(|l| l.kind == StructuredLineKind::Add)
590            .expect("added line");
591        assert_eq!(line_text(add), "foo qux baz");
592        assert!(add.segments.len() > 1, "added line keeps word segments");
593    }
594
595    fn git_diff_u0(old: &str, new: &str) -> String {
596        let tmp = tempfile::tempdir().expect("tempdir");
597        let path = tmp.path().join("f.txt");
598        std::fs::write(&path, old).expect("write old");
599        Command::new("git")
600            .args(["init", "-q", "-b", "main"])
601            .current_dir(tmp.path())
602            .status()
603            .expect("git init");
604        for (k, v) in [("user.email", "t@e.com"), ("user.name", "T")] {
605            Command::new("git")
606                .args(["config", k, v])
607                .current_dir(tmp.path())
608                .status()
609                .unwrap();
610        }
611        Command::new("git")
612            .args(["add", "f.txt"])
613            .current_dir(tmp.path())
614            .status()
615            .unwrap();
616        Command::new("git")
617            .args(["commit", "-qm", "init"])
618            .current_dir(tmp.path())
619            .status()
620            .unwrap();
621        std::fs::write(&path, new).expect("write new");
622        let out = Command::new("git")
623            .args(["diff", "-U0", "f.txt"])
624            .current_dir(tmp.path())
625            .output()
626            .expect("git diff");
627        String::from_utf8(out.stdout).expect("utf8")
628    }
629
630    #[cfg_attr(
631        not(feature = "full-tests"),
632        ignore = "starts system git or is heavy; run with --features full-tests"
633    )]
634    #[test]
635    fn zero_context_insertion_anchor_matches_git() {
636        let old = "a\nb\nc\n";
637        let new = "a\nb\nX\nc\n";
638        let patch = git_diff_u0(old, new);
639        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
640        let d = structured_text_diff(
641            old,
642            new,
643            DiffOptions {
644                context_lines: 0,
645                ..DiffOptions::default()
646            },
647        );
648        let h = &d.hunks[0];
649        assert_eq!(
650            (h.old_start, h.old_lines, h.new_start, h.new_lines),
651            (g_os, g_ol, g_ns, g_nl)
652        );
653        assert_eq!((h.old_start, h.old_lines), (2, 0));
654    }
655
656    #[cfg_attr(
657        not(feature = "full-tests"),
658        ignore = "starts system git or is heavy; run with --features full-tests"
659    )]
660    #[test]
661    fn zero_context_deletion_anchor_matches_git() {
662        let old = "a\nb\nc\n";
663        let new = "a\nc\n";
664        let patch = git_diff_u0(old, new);
665        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
666        let d = structured_text_diff(
667            old,
668            new,
669            DiffOptions {
670                context_lines: 0,
671                ..DiffOptions::default()
672            },
673        );
674        let h = &d.hunks[0];
675        assert_eq!(
676            (h.old_start, h.old_lines, h.new_start, h.new_lines),
677            (g_os, g_ol, g_ns, g_nl)
678        );
679        assert_eq!((h.new_start, h.new_lines), (1, 0));
680    }
681
682    #[cfg_attr(
683        not(feature = "full-tests"),
684        ignore = "starts system git or is heavy; run with --features full-tests"
685    )]
686    #[test]
687    fn structured_hunk_retains_enclosing_function_context() {
688        let old = "fn foo() {\n  let x = 1;\n}\n";
689        let new = "fn foo() {\n  let x = 2;\n}\n";
690        let mut opts = DiffOptions::default();
691        opts.function_context = true;
692        let d = structured_text_diff(old, new, opts);
693        let h = &d.hunks[0];
694        assert_eq!(h.context.as_deref(), Some("fn foo() {"));
695    }
696
697    #[cfg_attr(
698        not(feature = "full-tests"),
699        ignore = "starts system git or is heavy; run with --features full-tests"
700    )]
701    #[test]
702    fn insert_into_empty_file_matches_git_hunk_header() {
703        let patch = git_diff("", "x\n");
704        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
705        let d = structured_text_diff("", "x\n", DiffOptions::default());
706        let h = &d.hunks[0];
707        assert_eq!(
708            (h.old_start, h.old_lines, h.new_start, h.new_lines),
709            (g_os, g_ol, g_ns, g_nl)
710        );
711        assert_eq!((h.old_start, h.old_lines), (0, 0));
712    }
713
714    #[cfg_attr(
715        not(feature = "full-tests"),
716        ignore = "starts system git or is heavy; run with --features full-tests"
717    )]
718    #[test]
719    fn delete_to_empty_matches_git_hunk_header() {
720        let patch = git_diff("x\n", "");
721        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
722        let d = structured_text_diff("x\n", "", DiffOptions::default());
723        let h = &d.hunks[0];
724        assert_eq!(
725            (h.old_start, h.old_lines, h.new_start, h.new_lines),
726            (g_os, g_ol, g_ns, g_nl)
727        );
728        assert_eq!((h.new_start, h.new_lines), (0, 0));
729    }
730
731    #[cfg_attr(
732        not(feature = "full-tests"),
733        ignore = "starts system git or is heavy; run with --features full-tests"
734    )]
735    #[test]
736    fn line_level_multiple_hunks_match_git() {
737        let old = "keep\nchange-a\nmid\nchange-b\nend\n";
738        let new = "keep\nCHANGE-A\nmid\nCHANGE-B\nend\n";
739        let patch = git_diff_u0(old, new);
740        let git_hunks: Vec<_> = patch.lines().filter(|l| l.starts_with("@@")).collect();
741        assert_eq!(git_hunks.len(), 2);
742        let d = structured_text_diff(
743            old,
744            new,
745            DiffOptions {
746                context_lines: 0,
747                word_level_inline: false,
748                ..DiffOptions::default()
749            },
750        );
751        assert_eq!(d.hunks.len(), 2);
752        for (h, header) in d.hunks.iter().zip(git_hunks) {
753            let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&format!("{header}\n"));
754            assert_eq!(
755                (h.old_start, h.old_lines, h.new_start, h.new_lines),
756                (g_os, g_ol, g_ns, g_nl)
757            );
758        }
759    }
760
761    #[test]
762    fn line_level_blank_line_in_hunk() {
763        let old = "a\n\nb\n";
764        let new = "a\nX\n\nb\n";
765        let d = structured_text_diff(
766            old,
767            new,
768            DiffOptions {
769                context_lines: 1,
770                word_level_inline: false,
771                ..DiffOptions::default()
772            },
773        );
774        assert!(!d.hunks.is_empty());
775        assert!(
776            d.hunks[0]
777                .lines
778                .iter()
779                .any(|l| l.segments[0].text.is_empty() && l.kind == StructuredLineKind::Context)
780                || d.hunks[0]
781                    .lines
782                    .iter()
783                    .any(|l| l.segments[0].text.is_empty())
784        );
785    }
786
787    #[test]
788    fn line_level_missing_eof_newline_flag() {
789        let old = "only";
790        let new = "only\n";
791        let d = structured_text_diff(
792            old,
793            new,
794            DiffOptions {
795                context_lines: 0,
796                word_level_inline: false,
797                ..DiffOptions::default()
798            },
799        );
800        let h = &d.hunks[0];
801        let add = h
802            .lines
803            .iter()
804            .find(|l| l.kind == StructuredLineKind::Add)
805            .expect("add line");
806        assert!(add.no_newline_at_eof || h.lines.iter().any(|l| l.no_newline_at_eof));
807    }
808
809    #[test]
810    fn hunk_header_counts_match_line_totals() {
811        let old = "x\n";
812        let new = "x\nmore\n";
813        let d = structured_text_diff(
814            old,
815            new,
816            DiffOptions {
817                context_lines: 3,
818                ..DiffOptions::default()
819            },
820        );
821        let h = &d.hunks[0];
822        assert_eq!(h.old_lines, 1);
823        assert_eq!(h.new_lines, 2);
824    }
825
826    #[test]
827    fn mode_only_change_has_empty_hunks() {
828        let old = SideBytes::from_bytes(b"x\n".to_vec());
829        let new = SideBytes::from_bytes(b"x\n".to_vec());
830        let f = structured_file_diff(
831            FileMode::REGULAR,
832            FileMode::EXECUTABLE,
833            &old,
834            &new,
835            DiffOptions::default(),
836        );
837        assert!(f.hunks.is_empty());
838    }
839}