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