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