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