1use similar::{Algorithm, ChangeTag, TextDiff};
11
12const ALGORITHM: Algorithm = Algorithm::Histogram;
20
21pub(crate) const DEFAULT_CONTEXT: usize = 3;
23
24pub struct LineDiff {
31 pub body: String,
32 pub lines_added: usize,
33 pub lines_removed: usize,
34 pub old_lines: usize,
35 pub new_lines: usize,
36 pub changes: Vec<LineChange>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LineChangeKind {
42 Equal,
43 Delete,
44 Insert,
45}
46
47impl LineChangeKind {
48 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Equal => "equal",
52 Self::Delete => "delete",
53 Self::Insert => "insert",
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct LineChange {
61 pub kind: LineChangeKind,
62 pub line: String,
63 pub old_line: usize,
64 pub new_line: usize,
65}
66
67#[derive(Debug, Clone, Copy)]
69pub struct LineDiffOptions {
70 pub context: usize,
71 pub include_body: bool,
72 pub include_changes: bool,
73}
74
75impl Default for LineDiffOptions {
76 fn default() -> Self {
77 Self {
78 context: DEFAULT_CONTEXT,
79 include_body: true,
80 include_changes: false,
81 }
82 }
83}
84
85pub fn render_line_diff(before: &str, after: &str) -> LineDiff {
91 compute_line_diff(before, after, LineDiffOptions::default())
92}
93
94pub fn compute_line_diff(before: &str, after: &str, options: LineDiffOptions) -> LineDiff {
96 let diff = TextDiff::configure()
97 .algorithm(ALGORITHM)
98 .diff_lines(before, after);
99 let body = if options.include_body {
100 diff.unified_diff()
101 .context_radius(options.context)
102 .to_string()
103 } else {
104 String::new()
105 };
106
107 let mut lines_added = 0;
108 let mut lines_removed = 0;
109 let mut old_line = 1;
110 let mut new_line = 1;
111 let mut changes = if options.include_changes {
112 Vec::with_capacity(diff.old_len().max(diff.new_len()))
113 } else {
114 Vec::new()
115 };
116 for change in diff.iter_all_changes() {
117 let kind = match change.tag() {
118 ChangeTag::Insert => {
119 lines_added += 1;
120 LineChangeKind::Insert
121 }
122 ChangeTag::Delete => {
123 lines_removed += 1;
124 LineChangeKind::Delete
125 }
126 ChangeTag::Equal => LineChangeKind::Equal,
127 };
128 if options.include_changes {
129 changes.push(LineChange {
130 kind,
131 line: line_without_terminator(change.value()),
132 old_line,
133 new_line,
134 });
135 }
136 match kind {
137 LineChangeKind::Equal => {
138 old_line += 1;
139 new_line += 1;
140 }
141 LineChangeKind::Delete => old_line += 1,
142 LineChangeKind::Insert => new_line += 1,
143 }
144 }
145
146 LineDiff {
147 body,
148 lines_added,
149 lines_removed,
150 old_lines: diff.old_len(),
151 new_lines: diff.new_len(),
152 changes,
153 }
154}
155
156fn line_without_terminator(value: &str) -> String {
157 let value = value.strip_suffix('\n').unwrap_or(value);
158 value.strip_suffix('\r').unwrap_or(value).to_owned()
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn identical_inputs_yield_empty_body() {
167 let diff = render_line_diff("a\nb\nc\n", "a\nb\nc\n");
168 assert_eq!(diff.body, "");
169 assert_eq!(diff.lines_added, 0);
170 assert_eq!(diff.lines_removed, 0);
171 }
172
173 #[test]
174 fn single_change_emits_bounded_hunk() {
175 let diff = render_line_diff("a\nb\nc\n", "a\nB\nc\n");
176 assert!(diff.body.starts_with("@@ -"));
177 assert!(diff.body.contains("-b\n"));
178 assert!(diff.body.contains("+B\n"));
179 assert_eq!(diff.lines_added, 1);
180 assert_eq!(diff.lines_removed, 1);
181 }
182
183 #[test]
184 fn expanded_changes_keep_one_based_coordinates() {
185 let diff = compute_line_diff(
186 "a\nb\nc\n",
187 "a\nB\nc\n",
188 LineDiffOptions {
189 include_body: false,
190 include_changes: true,
191 ..LineDiffOptions::default()
192 },
193 );
194 assert_eq!(diff.old_lines, 3);
195 assert_eq!(diff.new_lines, 3);
196 assert_eq!(diff.changes[1].kind, LineChangeKind::Delete);
197 assert_eq!(diff.changes[1].line, "b");
198 assert_eq!((diff.changes[1].old_line, diff.changes[1].new_line), (2, 2));
199 assert_eq!(diff.changes[2].kind, LineChangeKind::Insert);
200 assert_eq!(diff.changes[2].line, "B");
201 assert_eq!((diff.changes[2].old_line, diff.changes[2].new_line), (3, 2));
202 }
203
204 #[test]
205 fn context_stays_bounded_on_large_inputs() {
206 let before: String = (0..1000).map(|i| format!("line {i}\n")).collect();
207 let mut after_lines: Vec<String> = (0..1000).map(|i| format!("line {i}")).collect();
208 after_lines[500] = "CHANGED".to_string();
209 let after = after_lines
210 .iter()
211 .map(|l| format!("{l}\n"))
212 .collect::<String>();
213 let diff = render_line_diff(&before, &after);
214 assert_eq!(diff.body.matches("@@ -").count(), 1);
216 assert!(diff.body.lines().count() < 12);
217 assert!(!diff.body.contains("line 100\n"));
218 }
219
220 #[test]
221 fn trailing_newline_change_is_not_collapsed() {
222 let diff = render_line_diff("a\nb", "a\nb\n");
223 assert!(diff.body.contains("\\ No newline at end of file"));
224 }
225}