Skip to main content

atelier_sdk_diff/
text.rs

1use similar::{ChangeTag, TextDiff};
2
3use crate::model::{Line, LineKind};
4
5/// The bytes as text, when they are valid UTF-8 without NUL — the
6/// precondition for diffing at the text rung without a projection.
7#[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
16/// The marker line carried after a changed line that has no trailing
17/// newline, following git's convention, so a terminal-newline edit stays
18/// visible in the comparison.
19pub const NO_NEWLINE_MARKER: &str = "\\ no newline at end of file";
20
21/// The text rung: the line-level comparison of two texts.
22///
23/// Only changed lines are carried, in document order; an unchanged line
24/// yields nothing. The same inputs always yield the same lines. A carried
25/// line strips its one trailing `\n` but keeps a `\r` — a CRLF conversion
26/// stays visible — and a changed line without any trailing newline is
27/// followed by [`NO_NEWLINE_MARKER`].
28#[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}