oxicode_hashline/
diff_preview.rs1use crate::types::{CompactDiffOptions, CompactDiffPreview};
16
17const PREVIEW_ELISION_MARKER: &str = "…";
20const PREVIEW_GAP_ROW: &str = "";
22
23fn is_preview_separator(line: &str) -> bool {
25 line == PREVIEW_ELISION_MARKER || line == PREVIEW_GAP_ROW
26}
27
28fn append_preview_line(output: &mut Vec<String>, line: &str) {
33 let normalized: &str = match line {
34 "..." | "…" | "+…" => PREVIEW_ELISION_MARKER,
35 _ => line,
36 };
37 if is_preview_separator(normalized)
38 && (output.is_empty()
39 || output
40 .last()
41 .map(|l| is_preview_separator(l))
42 .unwrap_or(false))
43 {
44 return;
45 }
46 output.push(normalized.to_string());
47}
48
49fn append_added_run(output: &mut Vec<String>, run: &[String], edge: usize) {
52 if run.is_empty() {
53 return;
54 }
55 let edge = edge.max(1);
56 let collapse_threshold = edge * 2 + 1;
57 if run.len() <= collapse_threshold {
58 for text in run {
59 append_preview_line(output, text);
60 }
61 return;
62 }
63 for text in &run[..edge] {
64 append_preview_line(output, text);
65 }
66 append_preview_line(output, PREVIEW_ELISION_MARKER);
67 for text in &run[run.len() - edge..] {
68 append_preview_line(output, text);
69 }
70}
71
72fn flush(output: &mut Vec<String>, run: &mut Vec<String>, edge: usize) {
74 append_added_run(output, run, edge);
75 run.clear();
76}
77
78#[derive(Debug)]
80enum Op {
81 Keep { post: u32, content: String },
83 Remove,
85 Insert { post: u32, content: String },
87}
88
89impl Op {
90 fn is_keep(&self) -> bool {
91 matches!(self, Op::Keep { .. })
92 }
93}
94
95fn diff_ops(before: &[&str], after: &[&str]) -> Vec<Op> {
98 let m = before.len();
99 let n = after.len();
100
101 let mut dp = vec![vec![0u32; n + 1]; m + 1];
103 for i in (0..m).rev() {
104 for j in (0..n).rev() {
105 dp[i][j] = if before[i] == after[j] {
106 dp[i + 1][j + 1] + 1
107 } else {
108 dp[i + 1][j].max(dp[i][j + 1])
109 };
110 }
111 }
112
113 let mut ops = Vec::with_capacity(m + n);
114 let (mut i, mut j, mut post) = (0usize, 0usize, 1u32);
115 while i < m && j < n {
116 if before[i] == after[j] {
117 ops.push(Op::Keep {
118 post,
119 content: before[i].to_string(),
120 });
121 i += 1;
122 j += 1;
123 post += 1;
124 } else if dp[i + 1][j] >= dp[i][j + 1] {
125 ops.push(Op::Remove);
126 i += 1;
127 } else {
128 ops.push(Op::Insert {
129 post,
130 content: after[j].to_string(),
131 });
132 j += 1;
133 post += 1;
134 }
135 }
136 while i < m {
137 ops.push(Op::Remove);
138 i += 1;
139 }
140 while j < n {
141 ops.push(Op::Insert {
142 post,
143 content: after[j].to_string(),
144 });
145 j += 1;
146 post += 1;
147 }
148 ops
149}
150
151pub fn build_compact_diff_preview(
156 before: &str,
157 after: &str,
158 opts: &CompactDiffOptions,
159) -> CompactDiffPreview {
160 let ctx = opts.max_unchanged_context.max(1);
161 let before_lines: Vec<&str> = before.split('\n').collect();
162 let after_lines: Vec<&str> = after.split('\n').collect();
163 let ops = diff_ops(&before_lines, &after_lines);
164
165 let n = ops.len();
168 let mut keep_visible = vec![false; n];
169 for center in 0..n {
170 if ops[center].is_keep() {
171 continue;
172 }
173 let mut c = 0usize;
174 let mut k = center;
175 while k > 0 && c < ctx {
176 k -= 1;
177 if ops[k].is_keep() {
178 keep_visible[k] = true;
179 c += 1;
180 }
181 }
182 c = 0;
183 let mut k = center + 1;
184 while k < n && c < ctx {
185 if ops[k].is_keep() {
186 keep_visible[k] = true;
187 c += 1;
188 }
189 k += 1;
190 }
191 }
192
193 let mut output: Vec<String> = Vec::new();
194 let mut added_run: Vec<String> = Vec::new();
195 for (idx, op) in ops.iter().enumerate() {
196 match op {
197 Op::Keep { post, content } => {
198 if !keep_visible[idx] {
199 continue;
200 }
201 flush(&mut output, &mut added_run, ctx);
202 append_preview_line(&mut output, &format!("{post}:{content}"));
203 }
204 Op::Remove => {
205 flush(&mut output, &mut added_run, ctx);
206 }
207 Op::Insert { post, content } => {
208 added_run.push(format!("{post}:{content}"));
209 }
210 }
211 }
212 flush(&mut output, &mut added_run, ctx);
213
214 while output
216 .last()
217 .map(|l| is_preview_separator(l))
218 .unwrap_or(false)
219 {
220 output.pop();
221 }
222
223 CompactDiffPreview { lines: output }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn preview(before: &str, after: &str) -> Vec<String> {
231 build_compact_diff_preview(before, after, &CompactDiffOptions::default()).lines
232 }
233
234 #[test]
235 fn identical_input_is_empty() {
236 assert!(preview("a\nb\nc", "a\nb\nc").is_empty());
237 }
238
239 #[test]
240 fn pure_insert_numbers_at_post_edit_positions() {
241 let lines = preview("a\nb\nc", "x\na\nb\nc");
242 assert!(lines.iter().any(|l| l == "1:x"), "insert x at post line 1");
243 assert!(lines.iter().any(|l| l.starts_with("2:a")));
245 }
246
247 #[test]
248 fn replace_shows_insert_and_surrounding_context() {
249 let lines = preview("a\nb\nc", "a\nB\nc");
250 assert!(lines.iter().any(|l| l == "2:B"));
252 assert!(lines.iter().any(|l| l == "1:a"));
253 assert!(lines.iter().any(|l| l == "3:c"));
254 assert!(!lines.iter().any(|l| l.ends_with(":b")));
256 }
257
258 #[test]
259 fn trailing_insert_is_emitted() {
260 let lines = preview("a", "a\nb");
261 assert!(lines.iter().any(|l| l == "2:b"));
262 }
263
264 #[test]
265 fn long_added_run_collapses_with_marker() {
266 let after: Vec<&str> = (0..10).map(|_| "x").collect();
268 let lines = preview("", &after.join("\n"));
269 assert!(lines.iter().any(|l| l == "…"), "elision marker present");
270 assert!(lines.len() <= 7);
272 assert!(lines.first().map(|l| l == "1:x").unwrap_or(false));
274 }
275
276 #[test]
277 fn unchanged_lines_far_from_change_are_trimmed() {
278 let before: String = "head\n".to_string() + &"tail\n".repeat(50);
280 let after: String = "CHANGED\n".to_string() + &"tail\n".repeat(50);
281 let lines = preview(&before, &after);
282 assert!(lines.iter().any(|l| l == "1:CHANGED"));
283 assert!(lines.len() < 20, "trimmed: got {} lines", lines.len());
285 }
286
287 #[test]
288 fn context_window_respects_option() {
289 let opts = CompactDiffOptions {
291 max_unchanged_context: 1,
292 };
293 let lines = build_compact_diff_preview("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", &opts).lines;
294 assert!(lines.iter().any(|l| l == "2:b"));
296 assert!(lines.iter().any(|l| l == "3:X"));
297 assert!(lines.iter().any(|l| l == "4:d"));
298 assert!(!lines.iter().any(|l| l == "1:a"));
300 assert!(!lines.iter().any(|l| l == "5:e"));
301 }
302
303 #[test]
304 fn options_min_clamps_to_one() {
305 let opts = CompactDiffOptions {
306 max_unchanged_context: 0,
307 };
308 let lines = build_compact_diff_preview("a\nb", "a\nB", &opts).lines;
310 assert!(lines.iter().any(|l| l == "2:B"));
311 }
312
313 #[test]
314 fn elision_marker_is_unique_and_interior() {
315 let after: Vec<&str> = (0..10).map(|_| "x").collect();
318 let lines = preview("", &after.join("\n"));
319 let markers: Vec<&String> = lines.iter().filter(|l| **l == "…").collect();
320 assert_eq!(
321 markers.len(),
322 1,
323 "exactly one elision marker, never stacked"
324 );
325 assert_ne!(
326 lines.first().map(String::as_str),
327 Some("…"),
328 "no leading marker"
329 );
330 assert_ne!(
331 lines.last().map(String::as_str),
332 Some("…"),
333 "no trailing marker"
334 );
335 }
336}