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 DiffDisplayLine {
37 pub fn numbered_text(&self, line_number_width: usize) -> String {
42 match self.kind {
43 DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
44 DiffDisplayKind::Addition => {
45 format!("+{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
46 }
47 DiffDisplayKind::Deletion => {
48 format!("-{:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
49 }
50 DiffDisplayKind::Context => {
51 format!(" {:>line_number_width$} │ {}", self.line_number.unwrap_or_default(), self.text)
52 }
53 }
54 }
55}
56
57impl DiffChangeCounts {
58 pub fn total(self) -> usize {
59 self.additions + self.deletions
60 }
61}
62
63pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
64 let mut counts = DiffChangeCounts::default();
65
66 for hunk in hunks {
67 for line in &hunk.lines {
68 match line.kind {
69 DiffLineKind::Addition => counts.additions += 1,
70 DiffLineKind::Deletion => counts.deletions += 1,
71 DiffLineKind::Context => {}
72 }
73 }
74 }
75
76 counts
77}
78
79pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
80 let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
83 let mut lines = Vec::with_capacity(total);
84
85 for hunk in hunks {
86 lines.push(DiffDisplayLine {
87 kind: DiffDisplayKind::HunkHeader,
88 line_number: None,
89 text: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
90 });
91
92 for line in &hunk.lines {
93 lines.push(display_line_from_diff_line(line));
94 }
95 }
96
97 lines
98}
99
100pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
101 let mut lines = Vec::with_capacity(diff_content.lines().count());
104 let mut old_line_no = 0u32;
105 let mut new_line_no = 0u32;
106 let mut in_hunk = false;
107
108 for line in diff_content.lines() {
109 if let Some((old_start, new_start)) = parse_hunk_starts(line) {
110 old_line_no = old_start as u32;
111 new_line_no = new_start as u32;
112 in_hunk = true;
113 lines.push(DiffDisplayLine {
114 kind: DiffDisplayKind::HunkHeader,
115 line_number: None,
116 text: format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
117 });
118 continue;
119 }
120
121 if !in_hunk {
122 lines.push(DiffDisplayLine {
123 kind: DiffDisplayKind::Metadata,
124 line_number: None,
125 text: line.to_string(),
126 });
127 continue;
128 }
129
130 if is_diff_addition_line(line) {
131 lines.push(DiffDisplayLine {
132 kind: DiffDisplayKind::Addition,
133 line_number: Some(new_line_no),
134 text: line[1..].to_string(),
135 });
136 new_line_no = new_line_no.saturating_add(1);
137 continue;
138 }
139
140 if is_diff_deletion_line(line) {
141 lines.push(DiffDisplayLine {
142 kind: DiffDisplayKind::Deletion,
143 line_number: Some(old_line_no),
144 text: line[1..].to_string(),
145 });
146 old_line_no = old_line_no.saturating_add(1);
147 continue;
148 }
149
150 if let Some(context_line) = line.strip_prefix(' ') {
151 lines.push(DiffDisplayLine {
152 kind: DiffDisplayKind::Context,
153 line_number: Some(new_line_no),
154 text: context_line.to_string(),
155 });
156 old_line_no = old_line_no.saturating_add(1);
157 new_line_no = new_line_no.saturating_add(1);
158 continue;
159 }
160
161 lines.push(DiffDisplayLine {
162 kind: DiffDisplayKind::Metadata,
163 line_number: None,
164 text: line.to_string(),
165 });
166 }
167
168 lines
169}
170
171pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
172 let max_digits = lines
173 .iter()
174 .filter_map(|line| line.line_number.map(|line_no| line_no.to_string().len()))
175 .max()
176 .unwrap_or(4);
177 max_digits.clamp(5, 6)
178}
179
180pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
181 let display_lines = display_lines_from_unified_diff(diff_content);
182 let width = diff_display_line_number_width(&display_lines);
183 display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
184}
185
186fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
187 let text = line.text.trim_end_matches('\n').to_string();
188 match line.kind {
189 DiffLineKind::Context => DiffDisplayLine {
190 kind: DiffDisplayKind::Context,
191 line_number: line.new_line,
192 text,
193 },
194 DiffLineKind::Addition => DiffDisplayLine {
195 kind: DiffDisplayKind::Addition,
196 line_number: line.new_line,
197 text,
198 },
199 DiffLineKind::Deletion => DiffDisplayLine {
200 kind: DiffDisplayKind::Deletion,
201 line_number: line.old_line,
202 text,
203 },
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::diff::{DiffLine, DiffLineKind};
211
212 #[test]
213 fn counts_diff_changes_from_hunks() {
214 let hunks = vec![DiffHunk {
215 old_start: 1,
216 old_lines: 2,
217 new_start: 1,
218 new_lines: 2,
219 lines: vec![
220 DiffLine {
221 kind: DiffLineKind::Context,
222 old_line: Some(1),
223 new_line: Some(1),
224 text: "same\n".to_string(),
225 },
226 DiffLine {
227 kind: DiffLineKind::Deletion,
228 old_line: Some(2),
229 new_line: None,
230 text: "old\n".to_string(),
231 },
232 DiffLine {
233 kind: DiffLineKind::Addition,
234 old_line: None,
235 new_line: Some(2),
236 text: "new\n".to_string(),
237 },
238 ],
239 }];
240
241 let counts = count_diff_changes(&hunks);
242 assert_eq!(counts.additions, 1);
243 assert_eq!(counts.deletions, 1);
244 assert_eq!(counts.total(), 2);
245 }
246
247 #[test]
248 fn formats_numbered_unified_diff_with_start_only_headers() {
249 let diff = "\
250diff --git a/file.txt b/file.txt
251@@ -10,2 +10,2 @@
252-old
253+new
254 context
255";
256
257 let lines = format_numbered_unified_diff(diff);
258 assert_eq!(lines[0], "diff --git a/file.txt b/file.txt");
259 assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
260 assert!(lines.iter().any(|line| line.starts_with("- 10 │ old")));
261 assert!(lines.iter().any(|line| line.starts_with("+ 10 │ new")));
262 assert!(lines.iter().any(|line| line.starts_with(" 11 │ context")));
263 }
264
265 #[test]
266 fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
267 let line = DiffDisplayLine {
268 kind: DiffDisplayKind::Addition,
269 line_number: Some(53),
270 text: "- **Agent-first by design**: prose".to_string(),
271 };
272 assert_eq!(line.numbered_text(5), "+ 53 │ - **Agent-first by design**: prose");
273 }
274
275 #[test]
276 fn display_lines_from_hunks_preserves_semantics() {
277 let hunks = vec![DiffHunk {
278 old_start: 10,
279 old_lines: 2,
280 new_start: 10,
281 new_lines: 2,
282 lines: vec![
283 DiffLine {
284 kind: DiffLineKind::Deletion,
285 old_line: Some(10),
286 new_line: None,
287 text: "old\n".to_string(),
288 },
289 DiffLine {
290 kind: DiffLineKind::Addition,
291 old_line: None,
292 new_line: Some(10),
293 text: "new\n".to_string(),
294 },
295 DiffLine {
296 kind: DiffLineKind::Context,
297 old_line: Some(11),
298 new_line: Some(11),
299 text: "same\n".to_string(),
300 },
301 ],
302 }];
303
304 let lines = display_lines_from_hunks(&hunks);
305 assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
306 assert_eq!(lines[0].text, "@@ -10 +10 @@");
307 assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
308 assert_eq!(lines[1].line_number, Some(10));
309 assert_eq!(lines[1].text, "old");
310 assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
311 assert_eq!(lines[2].line_number, Some(10));
312 assert_eq!(lines[3].kind, DiffDisplayKind::Context);
313 assert_eq!(lines[3].line_number, Some(11));
314 }
315
316 #[test]
317 fn diff_display_line_number_width_tracks_max_digits() {
318 let lines = vec![
319 DiffDisplayLine {
320 kind: DiffDisplayKind::Addition,
321 line_number: Some(99),
322 text: "let a = 1;".to_string(),
323 },
324 DiffDisplayLine {
325 kind: DiffDisplayKind::Context,
326 line_number: Some(10_420),
327 text: "let b = 2;".to_string(),
328 },
329 ];
330
331 assert_eq!(diff_display_line_number_width(&lines), 5);
332 }
333
334 #[test]
335 fn preserves_plain_text_when_not_diff() {
336 let lines = format_numbered_unified_diff("plain text output");
337 assert_eq!(lines, vec!["plain text output".to_string()]);
338 }
339}