1#![expect(
2 clippy::string_slice,
3 clippy::cast_possible_truncation,
4 reason = "Preview offsets are derived from bounded diff lines and converted to the documented display width."
5)]
6
7use crate::diff::{DiffHunk, DiffLineKind};
10use crate::diff_paths::{
11 format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line, parse_hunk_starts,
12};
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub struct DiffChangeCounts {
16 pub additions: usize,
17 pub deletions: usize,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum DiffDisplayKind {
22 Metadata,
23 HunkHeader,
24 Context,
25 Addition,
26 Deletion,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct DiffDisplayLine {
31 pub kind: DiffDisplayKind,
32 pub line_number: Option<u32>,
33 pub text: String,
34}
35
36impl DiffDisplayKind {
37 pub fn is_diff(self) -> bool {
40 matches!(self, Self::Context | Self::Addition | Self::Deletion)
41 }
42}
43
44impl DiffDisplayLine {
45 pub fn is_diff(&self) -> bool {
47 self.kind.is_diff()
48 }
49
50 pub fn numbered_text(&self, line_number_width: usize) -> String {
55 match self.kind {
56 DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
57 DiffDisplayKind::Addition => {
58 format!("+{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
59 }
60 DiffDisplayKind::Deletion => {
61 format!("-{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
62 }
63 DiffDisplayKind::Context => {
64 format!(" {:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
65 }
66 }
67 }
68}
69
70impl DiffChangeCounts {
71 pub fn total(self) -> usize {
72 self.additions + self.deletions
73 }
74}
75
76pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
77 let mut counts = DiffChangeCounts::default();
78
79 for hunk in hunks {
80 for line in &hunk.lines {
81 match line.kind {
82 DiffLineKind::Addition => counts.additions += 1,
83 DiffLineKind::Deletion => counts.deletions += 1,
84 DiffLineKind::Context => {}
85 }
86 }
87 }
88
89 counts
90}
91
92pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
93 let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
96 let mut lines = Vec::with_capacity(total);
97
98 for hunk in hunks {
99 lines.push(DiffDisplayLine {
100 kind: DiffDisplayKind::HunkHeader,
101 line_number: None,
102 text: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
103 });
104
105 for line in &hunk.lines {
106 lines.push(display_line_from_diff_line(line));
107 }
108 }
109
110 lines
111}
112
113pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
114 let mut lines = Vec::with_capacity(diff_content.lines().count());
117 let mut old_line_no = 0u32;
118 let mut new_line_no = 0u32;
119 let mut in_hunk = false;
120
121 for line in diff_content.lines() {
122 if let Some((old_start, new_start)) = parse_hunk_starts(line) {
123 old_line_no = old_start as u32;
124 new_line_no = new_start as u32;
125 in_hunk = true;
126 lines.push(DiffDisplayLine {
127 kind: DiffDisplayKind::HunkHeader,
128 line_number: None,
129 text: format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
130 });
131 continue;
132 }
133
134 if !in_hunk {
135 lines.push(DiffDisplayLine {
136 kind: DiffDisplayKind::Metadata,
137 line_number: None,
138 text: line.to_string(),
139 });
140 continue;
141 }
142
143 if is_diff_addition_line(line) {
144 lines.push(DiffDisplayLine {
145 kind: DiffDisplayKind::Addition,
146 line_number: Some(new_line_no),
147 text: line[1..].to_string(),
148 });
149 new_line_no = new_line_no.saturating_add(1);
150 continue;
151 }
152
153 if is_diff_deletion_line(line) {
154 lines.push(DiffDisplayLine {
155 kind: DiffDisplayKind::Deletion,
156 line_number: Some(old_line_no),
157 text: line[1..].to_string(),
158 });
159 old_line_no = old_line_no.saturating_add(1);
160 continue;
161 }
162
163 if let Some(context_line) = line.strip_prefix(' ') {
164 lines.push(DiffDisplayLine {
165 kind: DiffDisplayKind::Context,
166 line_number: Some(new_line_no),
167 text: context_line.to_string(),
168 });
169 old_line_no = old_line_no.saturating_add(1);
170 new_line_no = new_line_no.saturating_add(1);
171 continue;
172 }
173
174 if let Some(omitted) = parse_omitted_line_count(line) {
175 old_line_no = old_line_no.saturating_add(omitted);
176 new_line_no = new_line_no.saturating_add(omitted);
177 lines.push(DiffDisplayLine {
178 kind: DiffDisplayKind::Metadata,
179 line_number: None,
180 text: line.to_string(),
181 });
182 continue;
183 }
184
185 lines.push(DiffDisplayLine {
186 kind: DiffDisplayKind::Metadata,
187 line_number: None,
188 text: line.to_string(),
189 });
190 }
191
192 lines
193}
194
195pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
196 let max_digits = lines
197 .iter()
198 .filter_map(|line| line.line_number)
199 .map(digit_count)
200 .max()
201 .unwrap_or(4);
202 max_digits.clamp(5, 6)
203}
204
205fn digit_count(mut value: u32) -> usize {
206 let mut digits = 1;
207 while value >= 10 {
208 value /= 10;
209 digits += 1;
210 }
211 digits
212}
213
214pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
215 let display_lines = display_lines_from_unified_diff(diff_content);
216 let width = diff_display_line_number_width(&display_lines);
217 display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
218}
219
220fn parse_omitted_line_count(line: &str) -> Option<u32> {
223 let trimmed = line.trim();
224 let after = trimmed.strip_prefix("...")?;
225 let after = after.trim_start();
226 let digits_end = after.find(|ch: char| !ch.is_ascii_digit())?;
227 if digits_end == 0 {
228 return None;
229 }
230 after[..digits_end].parse().ok()
231}
232
233fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
234 let text = line.text.trim_end_matches('\n').to_string();
235 match line.kind {
236 DiffLineKind::Context => DiffDisplayLine {
237 kind: DiffDisplayKind::Context,
238 line_number: line.new_line,
239 text,
240 },
241 DiffLineKind::Addition => DiffDisplayLine {
242 kind: DiffDisplayKind::Addition,
243 line_number: line.new_line,
244 text,
245 },
246 DiffLineKind::Deletion => DiffDisplayLine {
247 kind: DiffDisplayKind::Deletion,
248 line_number: line.old_line,
249 text,
250 },
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::diff::{DiffLine, DiffLineKind};
258
259 #[test]
260 fn counts_diff_changes_from_hunks() {
261 let hunks = vec![DiffHunk {
262 old_start: 1,
263 old_lines: 2,
264 new_start: 1,
265 new_lines: 2,
266 lines: vec![
267 DiffLine {
268 kind: DiffLineKind::Context,
269 old_line: Some(1),
270 new_line: Some(1),
271 text: "same\n".to_string(),
272 },
273 DiffLine {
274 kind: DiffLineKind::Deletion,
275 old_line: Some(2),
276 new_line: None,
277 text: "old\n".to_string(),
278 },
279 DiffLine {
280 kind: DiffLineKind::Addition,
281 old_line: None,
282 new_line: Some(2),
283 text: "new\n".to_string(),
284 },
285 ],
286 }];
287
288 let counts = count_diff_changes(&hunks);
289 assert_eq!(counts.additions, 1);
290 assert_eq!(counts.deletions, 1);
291 assert_eq!(counts.total(), 2);
292 }
293
294 #[test]
295 fn formats_numbered_unified_diff_with_start_only_headers() {
296 let diff = "\
297diff --git a/file.txt b/file.txt
298@@ -10,2 +10,2 @@
299-old
300+new
301 context
302";
303
304 let lines = format_numbered_unified_diff(diff);
305 assert_eq!(lines[0], "diff --git a/file.txt b/file.txt");
306 assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
307 assert!(lines.iter().any(|line| line.starts_with("- 10 │ old")));
308 assert!(lines.iter().any(|line| line.starts_with("+ 10 │ new")));
309 assert!(lines.iter().any(|line| line.starts_with(" 11 │ context")));
310 }
311
312 #[test]
313 fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
314 let line = DiffDisplayLine {
315 kind: DiffDisplayKind::Addition,
316 line_number: Some(53),
317 text: "- **Agent-first by design**: prose".to_string(),
318 };
319 assert_eq!(line.numbered_text(5), "+ 53 │ - **Agent-first by design**: prose");
320 }
321
322 #[test]
323 fn display_lines_from_hunks_preserves_semantics() {
324 let hunks = vec![DiffHunk {
325 old_start: 10,
326 old_lines: 2,
327 new_start: 10,
328 new_lines: 2,
329 lines: vec![
330 DiffLine {
331 kind: DiffLineKind::Deletion,
332 old_line: Some(10),
333 new_line: None,
334 text: "old\n".to_string(),
335 },
336 DiffLine {
337 kind: DiffLineKind::Addition,
338 old_line: None,
339 new_line: Some(10),
340 text: "new\n".to_string(),
341 },
342 DiffLine {
343 kind: DiffLineKind::Context,
344 old_line: Some(11),
345 new_line: Some(11),
346 text: "same\n".to_string(),
347 },
348 ],
349 }];
350
351 let lines = display_lines_from_hunks(&hunks);
352 assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
353 assert_eq!(lines[0].text, "@@ -10 +10 @@");
354 assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
355 assert_eq!(lines[1].line_number, Some(10));
356 assert_eq!(lines[1].text, "old");
357 assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
358 assert_eq!(lines[2].line_number, Some(10));
359 assert_eq!(lines[3].kind, DiffDisplayKind::Context);
360 assert_eq!(lines[3].line_number, Some(11));
361 }
362
363 #[test]
364 fn diff_display_line_number_width_tracks_max_digits() {
365 let lines = vec![
366 DiffDisplayLine {
367 kind: DiffDisplayKind::Addition,
368 line_number: Some(99),
369 text: "let a = 1;".to_string(),
370 },
371 DiffDisplayLine {
372 kind: DiffDisplayKind::Context,
373 line_number: Some(10_420),
374 text: "let b = 2;".to_string(),
375 },
376 ];
377
378 assert_eq!(diff_display_line_number_width(&lines), 5);
379 }
380
381 #[test]
382 fn preserves_plain_text_when_not_diff() {
383 let lines = format_numbered_unified_diff("plain text output");
384 assert_eq!(lines, vec!["plain text output".to_string()]);
385 }
386
387 #[test]
388 fn is_diff_discriminates_content_lines() {
389 let diff = "\
390diff --git a/file.txt b/file.txt
391@@ -1 +1 @@
392-old
393+new
394 context
395";
396 let lines = display_lines_from_unified_diff(diff);
397 assert_eq!(lines[0].kind, DiffDisplayKind::Metadata);
398 assert!(!lines[0].is_diff());
399 assert_eq!(lines[1].kind, DiffDisplayKind::HunkHeader);
400 assert!(!lines[1].is_diff());
401 assert!(lines[2].is_diff());
402 assert!(lines[3].is_diff());
403 assert!(lines[4].is_diff());
404 }
405
406 #[test]
407 fn omitted_marker_advances_both_counters() {
408 let diff = "\
409@@ -1,5 +1,5 @@
410-one
411... 3 lines omitted ...
412 old tail
413";
414
415 let lines = display_lines_from_unified_diff(diff);
416 assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
417 assert_eq!(lines[1].line_number, Some(1));
418 assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
419 assert_eq!(lines[2].line_number, None);
420 assert_eq!(lines[3].kind, DiffDisplayKind::Context);
421 assert_eq!(lines[3].line_number, Some(4));
422 }
423
424 #[test]
425 fn diff_display_line_number_width_clamps_to_bounds() {
426 let small = vec![DiffDisplayLine {
427 kind: DiffDisplayKind::Context,
428 line_number: Some(1),
429 text: "text".to_string(),
430 }];
431 assert_eq!(diff_display_line_number_width(&small), 5);
432
433 let large = vec![DiffDisplayLine {
434 kind: DiffDisplayKind::Context,
435 line_number: Some(100_000),
436 text: "text".to_string(),
437 }];
438 assert_eq!(diff_display_line_number_width(&large), 6);
439 }
440
441 #[test]
442 fn metadata_lines_stay_metadata_after_hunk() {
443 let diff = "\
444@@ -1 +1 @@
445-old
446\\ No newline at end of file
447+new
448";
449
450 let lines = display_lines_from_unified_diff(diff);
451 assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
452 assert_eq!(lines[2].line_number, None);
453 assert_eq!(lines[3].kind, DiffDisplayKind::Addition);
454 assert_eq!(lines[3].line_number, Some(1));
455 }
456}