1use std::io::{Result as IoResult, Write};
6
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9#[must_use]
11pub fn display_width_minus_ansi(s: &str) -> usize {
12 let mut w = 0usize;
13 let mut chars = s.chars().peekable();
14 while let Some(ch) = chars.next() {
15 if ch == '\x1b' {
16 if chars.peek() == Some(&'[') {
17 chars.next();
18 for c in chars.by_ref() {
19 if c.is_ascii_alphabetic() {
20 break;
21 }
22 }
23 }
24 continue;
25 }
26 w = w.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0));
27 }
28 w
29}
30
31#[must_use]
33pub fn terminal_columns() -> usize {
34 if let Ok(cols) = std::env::var("COLUMNS") {
35 if let Ok(w) = cols.parse::<usize>() {
36 if w > 0 {
37 return w;
38 }
39 }
40 }
41 if let Ok(output) = std::process::Command::new("stty")
42 .arg("size")
43 .stdin(std::process::Stdio::inherit())
44 .stderr(std::process::Stdio::null())
45 .output()
46 {
47 let s = String::from_utf8_lossy(&output.stdout);
48 let parts: Vec<&str> = s.split_whitespace().collect();
49 if parts.len() == 2 {
50 if let Ok(w) = parts[1].parse::<usize>() {
51 if w > 0 {
52 return w;
53 }
54 }
55 }
56 }
57 80
58}
59
60pub const FORMAT_PATCH_STAT_WIDTH: usize = 72;
62
63#[derive(Debug, Clone)]
64pub struct FileStatInput {
65 pub path_display: String,
66 pub insertions: usize,
67 pub deletions: usize,
68 pub is_binary: bool,
69}
70
71#[derive(Debug, Clone)]
73pub struct DiffstatOptions<'a> {
74 pub total_width: usize,
76 pub line_prefix: &'a str,
79 pub subtract_prefix_from_terminal: bool,
81 pub stat_name_width: Option<usize>,
83 pub stat_graph_width: Option<usize>,
85 pub stat_count: Option<usize>,
87 pub color_add: &'a str,
89 pub color_del: &'a str,
91 pub color_reset: &'a str,
93 pub graph_bar_slack: usize,
95 pub graph_prefix_budget_slack: usize,
97}
98
99fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
100 if it == 0 || max_change == 0 {
101 return 0;
102 }
103 if width <= 1 {
104 return if it > 0 { 1 } else { 0 };
105 }
106 1 + (it * (width - 1) / max_change)
107}
108
109fn decimal_width(n: usize) -> usize {
110 if n == 0 {
111 1
112 } else {
113 format!("{n}").len()
114 }
115}
116
117fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
119 let full_w = path.width();
120 if full_w <= area_width {
121 return (path.to_string(), full_w);
122 }
123 let mut len = area_width;
124 len = len.saturating_sub(3);
125 let mut byte_start = 0usize;
126 let mut name_w = full_w;
127 while name_w > len {
128 let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
129 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
130 name_w = name_w.saturating_sub(cw);
131 byte_start += ch.len_utf8();
132 }
133 let rest = &path[byte_start..];
134 if let Some(slash_idx) = rest.find('/') {
135 let after = &rest[slash_idx..];
136 let after_w = after.width();
137 if after_w <= area_width {
138 return (format!("...{}", after), after_w);
139 }
140 }
141 let s = format!("...{}", rest);
142 (s.clone(), s.width())
143}
144
145pub fn write_diffstat_block(
147 out: &mut impl Write,
148 files: &[FileStatInput],
149 opts: &DiffstatOptions<'_>,
150) -> IoResult<()> {
151 if files.is_empty() {
152 return Ok(());
153 }
154
155 let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
156 let shown = &files[..limit];
157
158 let mut max_len = 0usize;
159 let mut max_change = 0usize;
160 let mut number_width = 0usize;
161 let mut bin_width = 0usize;
162
163 for f in shown {
164 let w = f.path_display.width();
165 if max_len < w {
166 max_len = w;
167 }
168 if f.is_binary {
169 let w = 14 + decimal_width(f.insertions) + decimal_width(f.deletions);
170 if bin_width < w {
171 bin_width = w;
172 }
173 number_width = number_width.max(3);
174 continue;
175 }
176 let ch = f.insertions + f.deletions;
177 if max_change < ch {
178 max_change = ch;
179 }
180 }
181
182 let mut width = if opts.subtract_prefix_from_terminal {
183 terminal_columns()
184 .saturating_sub(display_width_minus_ansi(opts.line_prefix))
185 .saturating_add(opts.graph_prefix_budget_slack)
186 } else {
187 opts.total_width
188 };
189
190 number_width = number_width.max(decimal_width(max_change));
191
192 if width < 16 + 6 + number_width {
193 width = 16 + 6 + number_width;
194 }
195
196 let mut graph_width = if max_change + 4 > bin_width {
197 max_change
198 } else {
199 bin_width.saturating_sub(4)
200 };
201 if let Some(cap) = opts.stat_graph_width {
202 if cap > 0 && cap < graph_width {
203 graph_width = cap;
204 }
205 }
206
207 let mut name_width = match opts.stat_name_width {
208 Some(nw) if nw > 0 && nw < max_len => nw,
209 _ => max_len,
210 };
211
212 if name_width + number_width + 6 + graph_width > width {
213 let mut gw = graph_width;
214 let target_gw = width * 3 / 8;
215 if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
216 gw = target_gw.saturating_sub(number_width).saturating_sub(6);
217 if gw < 6 {
218 gw = 6;
219 }
220 }
221 graph_width = gw;
222 if let Some(cap) = opts.stat_graph_width {
223 if graph_width > cap {
224 graph_width = cap;
225 }
226 }
227 if name_width
228 > width
229 .saturating_sub(number_width)
230 .saturating_sub(6)
231 .saturating_sub(graph_width)
232 {
233 name_width = width
234 .saturating_sub(number_width)
235 .saturating_sub(6)
236 .saturating_sub(graph_width);
237 } else {
238 graph_width = width
239 .saturating_sub(number_width)
240 .saturating_sub(6)
241 .saturating_sub(name_width);
242 }
243 }
244
245 graph_width = graph_width.saturating_add(opts.graph_bar_slack);
246
247 let mut total_ins = 0usize;
248 let mut total_del = 0usize;
249
250 for f in shown {
251 let prefix = opts.line_prefix;
252 if f.is_binary {
253 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
254 if prefix.is_empty() {
255 writeln!(
256 out,
257 " {:<nw_name$} | {:>nw$} {} -> {} bytes",
258 display_name,
259 "Bin",
260 f.deletions,
261 f.insertions,
262 nw_name = name_width,
263 nw = number_width
264 )?;
265 } else {
266 writeln!(
267 out,
268 "{prefix}{:<nw_name$} | {:>nw$} {} -> {} bytes",
269 display_name,
270 "Bin",
271 f.deletions,
272 f.insertions,
273 nw_name = name_width,
274 nw = number_width
275 )?;
276 }
277 continue;
278 }
279
280 let added = f.insertions;
281 let deleted = f.deletions;
282 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
283
284 let mut add = added;
285 let mut del = deleted;
286 if graph_width <= max_change && max_change > 0 {
287 let total_scaled = scale_linear(added + del, graph_width, max_change);
288 let mut total = total_scaled;
289 if total < 2 && add > 0 && del > 0 {
290 total = 2;
291 }
292 if add < del {
293 add = scale_linear(add, graph_width, max_change);
294 del = total.saturating_sub(add);
295 } else {
296 del = scale_linear(del, graph_width, max_change);
297 add = total.saturating_sub(del);
298 }
299 }
300
301 total_ins = total_ins.saturating_add(added);
302 total_del = total_del.saturating_add(deleted);
303
304 let total = added + del;
305 if prefix.is_empty() {
306 write!(
307 out,
308 " {:<nw_name$} | {:>nw$}",
309 display_name,
310 total,
311 nw_name = name_width,
312 nw = number_width
313 )?;
314 } else {
315 write!(
316 out,
317 "{prefix}{:<nw_name$} | {:>nw$}",
318 display_name,
319 total,
320 nw_name = name_width,
321 nw = number_width
322 )?;
323 }
324 if total > 0 {
325 write!(out, " ")?;
326 }
327 if add > 0 {
328 if !opts.color_add.is_empty() {
329 write!(out, "{}", opts.color_add)?;
330 }
331 write!(out, "{}", "+".repeat(add))?;
332 if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
333 write!(out, "{}", opts.color_reset)?;
334 }
335 }
336 if del > 0 {
337 if !opts.color_del.is_empty() {
338 write!(out, "{}", opts.color_del)?;
339 }
340 write!(out, "{}", "-".repeat(del))?;
341 if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
342 write!(out, "{}", opts.color_reset)?;
343 }
344 }
345 writeln!(out)?;
346 }
347
348 if files.len() > limit {
349 if opts.line_prefix.is_empty() {
350 writeln!(out, " ...")?;
351 } else {
352 writeln!(out, "{}...", opts.line_prefix)?;
353 }
354 }
355
356 let files_changed = files.len();
357 let mut summary = if opts.line_prefix.is_empty() {
358 format!(
359 " {} file{} changed",
360 files_changed,
361 if files_changed == 1 { "" } else { "s" }
362 )
363 } else {
364 format!(
365 "{}{} file{} changed",
366 opts.line_prefix,
367 files_changed,
368 if files_changed == 1 { "" } else { "s" }
369 )
370 };
371 if total_ins > 0 {
372 summary.push_str(&format!(
373 ", {} insertion{}(+)",
374 total_ins,
375 if total_ins == 1 { "" } else { "s" }
376 ));
377 }
378 if total_del > 0 {
379 summary.push_str(&format!(
380 ", {} deletion{}(-)",
381 total_del,
382 if total_del == 1 { "" } else { "s" }
383 ));
384 }
385 if total_ins == 0 && total_del == 0 {
386 summary.push_str(", 0 insertions(+), 0 deletions(-)");
387 }
388 writeln!(out, "{summary}")?;
389
390 Ok(())
391}