1use 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SideBytes {
26 pub data: Vec<u8>,
28 pub encoding_lossy: bool,
30 pub size: u64,
32}
33
34impl SideBytes {
35 #[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 #[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 #[must_use]
59 pub fn byte_len(&self) -> u64 {
60 self.size
61 }
62
63 #[must_use]
65 pub fn text(&self) -> String {
66 String::from_utf8_lossy(&self.data).into_owned()
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct StructuredSegment {
73 pub text: String,
75 pub emphasis: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StructuredLineKind {
82 Context,
84 Add,
86 Del,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StructuredLine {
93 pub kind: StructuredLineKind,
95 pub old: Option<usize>,
97 pub new: Option<usize>,
99 pub segments: Vec<StructuredSegment>,
101 pub no_newline_at_eof: bool,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct StructuredHunk {
108 pub old_start: usize,
110 pub new_start: usize,
112 pub old_lines: usize,
114 pub new_lines: usize,
116 pub context: Option<String>,
118 pub lines: Vec<StructuredLine>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct StructuredTextDiff {
125 pub hunks: Vec<StructuredHunk>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct StructuredFileDiff {
132 pub old_mode: Option<String>,
134 pub new_mode: Option<String>,
136 pub old_encoding_lossy: bool,
138 pub new_encoding_lossy: bool,
140 pub hunks: Vec<StructuredHunk>,
142}
143
144#[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
159pub(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
170fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
172 text_bytes_for_diff(&side.data)
173}
174
175fn 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#[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
284fn 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 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
312fn 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#[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
347fn 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
364pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<&str> {
366 TextLines::from_text(text).lines
367}
368
369pub(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#[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}