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 pub is_unmerged: bool,
72}
73
74#[derive(Debug, Clone)]
76pub struct DiffstatOptions<'a> {
77 pub total_width: usize,
79 pub line_prefix: &'a str,
82 pub subtract_prefix_from_terminal: bool,
84 pub stat_name_width: Option<usize>,
86 pub stat_graph_width: Option<usize>,
88 pub stat_count: Option<usize>,
90 pub color_add: &'a str,
92 pub color_del: &'a str,
94 pub color_reset: &'a str,
96 pub graph_bar_slack: usize,
98 pub graph_prefix_budget_slack: usize,
100}
101
102fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
103 if it == 0 || max_change == 0 {
104 return 0;
105 }
106 if width <= 1 {
107 return if it > 0 { 1 } else { 0 };
108 }
109 1 + (it * (width - 1) / max_change)
110}
111
112fn decimal_width(n: usize) -> usize {
113 if n == 0 {
114 1
115 } else {
116 format!("{n}").len()
117 }
118}
119
120fn pad_name_to_display_width(s: &str, min_cols: usize) -> String {
126 let w = s.width();
127 if w >= min_cols {
128 return s.to_string();
129 }
130 let pad = min_cols - w;
131 let mut out = String::with_capacity(s.len() + pad);
132 out.push_str(s);
133 out.push_str(&" ".repeat(pad));
134 out
135}
136
137fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
138 let full_w = path.width();
139 if full_w <= area_width {
140 return (path.to_string(), full_w);
141 }
142 let mut len = area_width;
143 len = len.saturating_sub(3);
144 let mut byte_start = 0usize;
145 let mut name_w = full_w;
146 while name_w > len {
147 let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
148 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
149 name_w = name_w.saturating_sub(cw);
150 byte_start += ch.len_utf8();
151 }
152 let rest = &path[byte_start..];
153 if let Some(slash_idx) = rest.find('/') {
154 let after = &rest[slash_idx..];
155 let after_w = after.width();
156 if after_w <= area_width {
157 return (format!("...{}", after), after_w);
158 }
159 }
160 let s = format!("...{}", rest);
161 (s.clone(), s.width())
162}
163
164pub fn write_diffstat_block(
166 out: &mut impl Write,
167 files: &[FileStatInput],
168 opts: &DiffstatOptions<'_>,
169) -> IoResult<()> {
170 if files.is_empty() {
171 return Ok(());
172 }
173
174 let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
175 let shown = &files[..limit];
176
177 let mut max_len = 0usize;
178 let mut max_change = 0usize;
179 let mut number_width = 0usize;
180 let mut bin_width = 0usize;
181
182 for f in shown {
183 let w = f.path_display.width();
184 if max_len < w {
185 max_len = w;
186 }
187 if f.is_unmerged {
188 if bin_width < 8 {
190 bin_width = 8;
191 }
192 continue;
193 }
194 if f.is_binary {
195 let w = if f.insertions == 0 && f.deletions == 0 {
196 3
197 } else {
198 14 + decimal_width(f.insertions) + decimal_width(f.deletions)
199 };
200 if bin_width < w {
201 bin_width = w;
202 }
203 number_width = number_width.max(3);
204 continue;
205 }
206 let ch = f.insertions + f.deletions;
207 if max_change < ch {
208 max_change = ch;
209 }
210 }
211
212 let mut width = if opts.subtract_prefix_from_terminal {
213 terminal_columns()
214 .saturating_sub(display_width_minus_ansi(opts.line_prefix))
215 .saturating_add(opts.graph_prefix_budget_slack)
216 } else {
217 opts.total_width
218 };
219
220 number_width = number_width.max(decimal_width(max_change));
221
222 if width < 16 + 6 + number_width {
223 width = 16 + 6 + number_width;
224 }
225
226 let mut graph_width = if max_change + 4 > bin_width {
227 max_change
228 } else {
229 bin_width.saturating_sub(4)
230 };
231 if let Some(cap) = opts.stat_graph_width {
232 if cap > 0 && cap < graph_width {
233 graph_width = cap;
234 }
235 }
236
237 let mut name_width = match opts.stat_name_width {
238 Some(nw) if nw > 0 && nw < max_len => nw,
239 _ => max_len,
240 };
241
242 if name_width + number_width + 6 + graph_width > width {
243 let mut gw = graph_width;
244 let target_gw = width * 3 / 8;
245 if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
246 gw = target_gw.saturating_sub(number_width).saturating_sub(6);
247 if gw < 6 {
248 gw = 6;
249 }
250 }
251 graph_width = gw;
252 if let Some(cap) = opts.stat_graph_width {
253 if graph_width > cap {
254 graph_width = cap;
255 }
256 }
257 if name_width
258 > width
259 .saturating_sub(number_width)
260 .saturating_sub(6)
261 .saturating_sub(graph_width)
262 {
263 name_width = width
264 .saturating_sub(number_width)
265 .saturating_sub(6)
266 .saturating_sub(graph_width);
267 } else {
268 graph_width = width
269 .saturating_sub(number_width)
270 .saturating_sub(6)
271 .saturating_sub(name_width);
272 }
273 }
274
275 graph_width = graph_width.saturating_add(opts.graph_bar_slack);
276
277 let mut total_ins = 0usize;
278 let mut total_del = 0usize;
279
280 for f in shown {
281 let prefix = opts.line_prefix;
282 if f.is_unmerged {
283 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
284 let name_col = pad_name_to_display_width(&display_name, name_width);
285 if prefix.is_empty() {
288 writeln!(
289 out,
290 " {} | {:>nw$}",
291 name_col,
292 "Unmerged",
293 nw = number_width
294 )?;
295 } else {
296 writeln!(
297 out,
298 "{prefix}{} | {:>nw$}",
299 name_col,
300 "Unmerged",
301 nw = number_width
302 )?;
303 }
304 continue;
305 }
306 if f.is_binary {
307 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
308 let name_col = pad_name_to_display_width(&display_name, name_width);
309 if f.insertions == 0 && f.deletions == 0 {
310 if prefix.is_empty() {
311 writeln!(out, " {} | {:>nw$}", name_col, "Bin", nw = number_width)?;
312 } else {
313 writeln!(
314 out,
315 "{prefix}{} | {:>nw$}",
316 name_col,
317 "Bin",
318 nw = number_width
319 )?;
320 }
321 } else if prefix.is_empty() {
322 writeln!(
323 out,
324 " {} | {:>nw$} {} -> {} bytes",
325 name_col,
326 "Bin",
327 f.deletions,
328 f.insertions,
329 nw = number_width
330 )?;
331 } else {
332 writeln!(
333 out,
334 "{prefix}{} | {:>nw$} {} -> {} bytes",
335 name_col,
336 "Bin",
337 f.deletions,
338 f.insertions,
339 nw = number_width
340 )?;
341 }
342 continue;
343 }
344
345 let added = f.insertions;
346 let deleted = f.deletions;
347 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
348 let name_col = pad_name_to_display_width(&display_name, name_width);
349
350 let mut add = added;
351 let mut del = deleted;
352 if graph_width <= max_change && max_change > 0 {
353 let total_scaled = scale_linear(added + del, graph_width, max_change);
354 let mut total = total_scaled;
355 if total < 2 && add > 0 && del > 0 {
356 total = 2;
357 }
358 if add < del {
359 add = scale_linear(add, graph_width, max_change);
360 del = total.saturating_sub(add);
361 } else {
362 del = scale_linear(del, graph_width, max_change);
363 add = total.saturating_sub(del);
364 }
365 }
366
367 total_ins = total_ins.saturating_add(added);
368 total_del = total_del.saturating_add(deleted);
369
370 let total = added + del;
371 if prefix.is_empty() {
372 write!(out, " {} | {:>nw$}", name_col, total, nw = number_width)?;
373 } else {
374 write!(
375 out,
376 "{prefix}{} | {:>nw$}",
377 name_col,
378 total,
379 nw = number_width
380 )?;
381 }
382 if total > 0 {
383 write!(out, " ")?;
384 }
385 if add > 0 {
386 if !opts.color_add.is_empty() {
387 write!(out, "{}", opts.color_add)?;
388 }
389 write!(out, "{}", "+".repeat(add))?;
390 if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
391 write!(out, "{}", opts.color_reset)?;
392 }
393 }
394 if del > 0 {
395 if !opts.color_del.is_empty() {
396 write!(out, "{}", opts.color_del)?;
397 }
398 write!(out, "{}", "-".repeat(del))?;
399 if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
400 write!(out, "{}", opts.color_reset)?;
401 }
402 }
403 writeln!(out)?;
404 }
405
406 if files.len() > limit {
407 if opts.line_prefix.is_empty() {
408 writeln!(out, " ...")?;
409 } else {
410 writeln!(out, "{}...", opts.line_prefix)?;
411 }
412 }
413
414 for f in &files[limit..] {
417 if f.is_binary {
418 continue;
419 }
420 total_ins = total_ins.saturating_add(f.insertions);
421 total_del = total_del.saturating_add(f.deletions);
422 }
423
424 let files_changed = files.iter().filter(|f| !f.is_unmerged).count();
426 let mut summary = if opts.line_prefix.is_empty() {
427 format!(
428 " {} file{} changed",
429 files_changed,
430 if files_changed == 1 { "" } else { "s" }
431 )
432 } else {
433 format!(
434 "{}{} file{} changed",
435 opts.line_prefix,
436 files_changed,
437 if files_changed == 1 { "" } else { "s" }
438 )
439 };
440 if files_changed > 0 {
443 if total_ins > 0 {
444 summary.push_str(&format!(
445 ", {} insertion{}(+)",
446 total_ins,
447 if total_ins == 1 { "" } else { "s" }
448 ));
449 }
450 if total_del > 0 {
451 summary.push_str(&format!(
452 ", {} deletion{}(-)",
453 total_del,
454 if total_del == 1 { "" } else { "s" }
455 ));
456 }
457 if total_ins == 0 && total_del == 0 {
458 summary.push_str(", 0 insertions(+), 0 deletions(-)");
459 }
460 }
461 writeln!(out, "{summary}")?;
462
463 Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn pad_name_matches_git_display_columns_for_wide_chars() {
472 let truncated = ".../f再见";
474 assert_eq!(truncated.width(), 9);
475 let padded = pad_name_to_display_width(truncated, 10);
476 assert_eq!(padded.width(), 10);
477 assert_eq!(padded, ".../f再见 ");
478 }
479
480 #[test]
481 fn diffstat_name_width_10_matches_git_padding() {
482 let files = vec![FileStatInput {
483 path_display: "d你好/f再见".to_string(),
484 insertions: 0,
485 deletions: 0,
486 is_binary: false,
487 is_unmerged: false,
488 }];
489 let opts = DiffstatOptions {
490 total_width: 80,
491 line_prefix: "",
492 subtract_prefix_from_terminal: false,
493 stat_name_width: Some(10),
494 stat_graph_width: None,
495 stat_count: None,
496 color_add: "",
497 color_del: "",
498 color_reset: "",
499 graph_bar_slack: 0,
500 graph_prefix_budget_slack: 0,
501 };
502 let mut buf = Vec::new();
503 write_diffstat_block(&mut buf, &files, &opts).unwrap();
504 let s = String::from_utf8(buf).unwrap();
505 let line = s.lines().next().unwrap();
506 assert!(
507 line.contains(".../f再见 |"),
508 "expected two spaces before pipe like git, got {line:?}"
509 );
510 }
511}