1use similar::{ChangeTag, TextDiff};
2
3use crate::model::{Line, LineKind};
4
5#[must_use]
8pub fn as_text(bytes: &[u8]) -> Option<&str> {
9 let text = str::from_utf8(bytes).ok()?;
10 if text.contains('\0') {
11 return None;
12 }
13 Some(text)
14}
15
16pub const NO_NEWLINE_MARKER: &str = "\\ no newline at end of file";
20
21#[must_use]
29pub fn diff_lines(before: &str, after: &str) -> Vec<Line> {
30 let mut lines = Vec::new();
31 for change in TextDiff::from_lines(before, after).iter_all_changes() {
32 let kind = match change.tag() {
33 ChangeTag::Delete => LineKind::Removed,
34 ChangeTag::Insert => LineKind::Added,
35 ChangeTag::Equal => continue,
36 };
37 let raw = change.value();
38 let text = match raw.strip_suffix('\n') {
39 Some(stripped) => stripped,
40 None => raw,
41 };
42 let newline_missing = !raw.ends_with('\n');
43 lines.push(Line {
44 kind,
45 text: text.to_owned(),
46 });
47 if newline_missing {
48 lines.push(Line {
49 kind: LineKind::NoNewline,
50 text: NO_NEWLINE_MARKER.to_owned(),
51 });
52 }
53 }
54 lines
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 fn line(kind: LineKind, text: &str) -> Line {
62 Line {
63 kind,
64 text: text.to_owned(),
65 }
66 }
67
68 #[test]
69 fn valid_utf8_is_text() {
70 assert_eq!(as_text(b"hello\n"), Some("hello\n"));
71 }
72
73 #[test]
74 fn invalid_utf8_and_nul_bytes_are_not_text() {
75 assert_eq!(as_text(&[0xff, 0xfe]), None);
76 assert_eq!(as_text(b"he\0llo"), None);
77 }
78
79 #[test]
80 fn identical_texts_yield_no_lines() {
81 assert!(diff_lines("a\nb\n", "a\nb\n").is_empty());
82 }
83
84 #[test]
85 fn an_edited_line_yields_its_removal_and_addition() {
86 let lines = diff_lines("a\nold\nc\n", "a\nnew\nc\n");
87 assert_eq!(
88 lines,
89 vec![line(LineKind::Removed, "old"), line(LineKind::Added, "new")]
90 );
91 }
92
93 #[test]
94 fn growth_from_empty_yields_only_additions() {
95 let lines = diff_lines("", "a\nb\n");
96 assert_eq!(
97 lines,
98 vec![line(LineKind::Added, "a"), line(LineKind::Added, "b")]
99 );
100 }
101
102 #[test]
103 fn carriage_returns_stay_visible_in_carried_lines() {
104 let lines = diff_lines("same\n", "same\r\n");
105 assert_eq!(
106 lines,
107 vec![
108 line(LineKind::Removed, "same"),
109 line(LineKind::Added, "same\r")
110 ]
111 );
112 }
113
114 #[test]
115 fn a_missing_terminal_newline_carries_the_git_marker() {
116 let lines = diff_lines("same\n", "same");
117 assert_eq!(
118 lines,
119 vec![
120 line(LineKind::Removed, "same"),
121 line(LineKind::Added, "same"),
122 line(LineKind::NoNewline, NO_NEWLINE_MARKER),
123 ]
124 );
125 }
126}