use std::io::{Result as IoResult, Write};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[must_use]
pub fn display_width_minus_ansi(s: &str) -> usize {
let mut w = 0usize;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
w = w.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0));
}
w
}
#[must_use]
pub fn terminal_columns() -> usize {
if let Ok(cols) = std::env::var("COLUMNS") {
if let Ok(w) = cols.parse::<usize>() {
if w > 0 {
return w;
}
}
}
if let Ok(output) = std::process::Command::new("stty")
.arg("size")
.stdin(std::process::Stdio::inherit())
.stderr(std::process::Stdio::null())
.output()
{
let s = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() == 2 {
if let Ok(w) = parts[1].parse::<usize>() {
if w > 0 {
return w;
}
}
}
}
80
}
pub const FORMAT_PATCH_STAT_WIDTH: usize = 72;
#[derive(Debug, Clone)]
pub struct FileStatInput {
pub path_display: String,
pub insertions: usize,
pub deletions: usize,
pub is_binary: bool,
}
#[derive(Debug, Clone)]
pub struct DiffstatOptions<'a> {
pub total_width: usize,
pub line_prefix: &'a str,
pub subtract_prefix_from_terminal: bool,
pub stat_name_width: Option<usize>,
pub stat_graph_width: Option<usize>,
pub stat_count: Option<usize>,
pub color_add: &'a str,
pub color_del: &'a str,
pub color_reset: &'a str,
pub graph_bar_slack: usize,
pub graph_prefix_budget_slack: usize,
}
fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
if it == 0 || max_change == 0 {
return 0;
}
if width <= 1 {
return if it > 0 { 1 } else { 0 };
}
1 + (it * (width - 1) / max_change)
}
fn decimal_width(n: usize) -> usize {
if n == 0 {
1
} else {
format!("{n}").len()
}
}
fn pad_name_to_display_width(s: &str, min_cols: usize) -> String {
let w = s.width();
if w >= min_cols {
return s.to_string();
}
let pad = min_cols - w;
let mut out = String::with_capacity(s.len() + pad);
out.push_str(s);
out.push_str(&" ".repeat(pad));
out
}
fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
let full_w = path.width();
if full_w <= area_width {
return (path.to_string(), full_w);
}
let mut len = area_width;
len = len.saturating_sub(3);
let mut byte_start = 0usize;
let mut name_w = full_w;
while name_w > len {
let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
name_w = name_w.saturating_sub(cw);
byte_start += ch.len_utf8();
}
let rest = &path[byte_start..];
if let Some(slash_idx) = rest.find('/') {
let after = &rest[slash_idx..];
let after_w = after.width();
if after_w <= area_width {
return (format!("...{}", after), after_w);
}
}
let s = format!("...{}", rest);
(s.clone(), s.width())
}
pub fn write_diffstat_block(
out: &mut impl Write,
files: &[FileStatInput],
opts: &DiffstatOptions<'_>,
) -> IoResult<()> {
if files.is_empty() {
return Ok(());
}
let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
let shown = &files[..limit];
let mut max_len = 0usize;
let mut max_change = 0usize;
let mut number_width = 0usize;
let mut bin_width = 0usize;
for f in shown {
let w = f.path_display.width();
if max_len < w {
max_len = w;
}
if f.is_binary {
let w = 14 + decimal_width(f.insertions) + decimal_width(f.deletions);
if bin_width < w {
bin_width = w;
}
number_width = number_width.max(3);
continue;
}
let ch = f.insertions + f.deletions;
if max_change < ch {
max_change = ch;
}
}
let mut width = if opts.subtract_prefix_from_terminal {
terminal_columns()
.saturating_sub(display_width_minus_ansi(opts.line_prefix))
.saturating_add(opts.graph_prefix_budget_slack)
} else {
opts.total_width
};
number_width = number_width.max(decimal_width(max_change));
if width < 16 + 6 + number_width {
width = 16 + 6 + number_width;
}
let mut graph_width = if max_change + 4 > bin_width {
max_change
} else {
bin_width.saturating_sub(4)
};
if let Some(cap) = opts.stat_graph_width {
if cap > 0 && cap < graph_width {
graph_width = cap;
}
}
let mut name_width = match opts.stat_name_width {
Some(nw) if nw > 0 && nw < max_len => nw,
_ => max_len,
};
if name_width + number_width + 6 + graph_width > width {
let mut gw = graph_width;
let target_gw = width * 3 / 8;
if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
gw = target_gw.saturating_sub(number_width).saturating_sub(6);
if gw < 6 {
gw = 6;
}
}
graph_width = gw;
if let Some(cap) = opts.stat_graph_width {
if graph_width > cap {
graph_width = cap;
}
}
if name_width
> width
.saturating_sub(number_width)
.saturating_sub(6)
.saturating_sub(graph_width)
{
name_width = width
.saturating_sub(number_width)
.saturating_sub(6)
.saturating_sub(graph_width);
} else {
graph_width = width
.saturating_sub(number_width)
.saturating_sub(6)
.saturating_sub(name_width);
}
}
graph_width = graph_width.saturating_add(opts.graph_bar_slack);
let mut total_ins = 0usize;
let mut total_del = 0usize;
for f in shown {
let prefix = opts.line_prefix;
if f.is_binary {
let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
let name_col = pad_name_to_display_width(&display_name, name_width);
if prefix.is_empty() {
writeln!(
out,
" {} | {:>nw$} {} -> {} bytes",
name_col,
"Bin",
f.deletions,
f.insertions,
nw = number_width
)?;
} else {
writeln!(
out,
"{prefix}{} | {:>nw$} {} -> {} bytes",
name_col,
"Bin",
f.deletions,
f.insertions,
nw = number_width
)?;
}
continue;
}
let added = f.insertions;
let deleted = f.deletions;
let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
let name_col = pad_name_to_display_width(&display_name, name_width);
let mut add = added;
let mut del = deleted;
if graph_width <= max_change && max_change > 0 {
let total_scaled = scale_linear(added + del, graph_width, max_change);
let mut total = total_scaled;
if total < 2 && add > 0 && del > 0 {
total = 2;
}
if add < del {
add = scale_linear(add, graph_width, max_change);
del = total.saturating_sub(add);
} else {
del = scale_linear(del, graph_width, max_change);
add = total.saturating_sub(del);
}
}
total_ins = total_ins.saturating_add(added);
total_del = total_del.saturating_add(deleted);
let total = added + del;
if prefix.is_empty() {
write!(out, " {} | {:>nw$}", name_col, total, nw = number_width)?;
} else {
write!(
out,
"{prefix}{} | {:>nw$}",
name_col,
total,
nw = number_width
)?;
}
if total > 0 {
write!(out, " ")?;
}
if add > 0 {
if !opts.color_add.is_empty() {
write!(out, "{}", opts.color_add)?;
}
write!(out, "{}", "+".repeat(add))?;
if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
write!(out, "{}", opts.color_reset)?;
}
}
if del > 0 {
if !opts.color_del.is_empty() {
write!(out, "{}", opts.color_del)?;
}
write!(out, "{}", "-".repeat(del))?;
if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
write!(out, "{}", opts.color_reset)?;
}
}
writeln!(out)?;
}
if files.len() > limit {
if opts.line_prefix.is_empty() {
writeln!(out, " ...")?;
} else {
writeln!(out, "{}...", opts.line_prefix)?;
}
}
let files_changed = files.len();
let mut summary = if opts.line_prefix.is_empty() {
format!(
" {} file{} changed",
files_changed,
if files_changed == 1 { "" } else { "s" }
)
} else {
format!(
"{}{} file{} changed",
opts.line_prefix,
files_changed,
if files_changed == 1 { "" } else { "s" }
)
};
if total_ins > 0 {
summary.push_str(&format!(
", {} insertion{}(+)",
total_ins,
if total_ins == 1 { "" } else { "s" }
));
}
if total_del > 0 {
summary.push_str(&format!(
", {} deletion{}(-)",
total_del,
if total_del == 1 { "" } else { "s" }
));
}
if total_ins == 0 && total_del == 0 {
summary.push_str(", 0 insertions(+), 0 deletions(-)");
}
writeln!(out, "{summary}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_name_matches_git_display_columns_for_wide_chars() {
let truncated = ".../f再见";
assert_eq!(truncated.width(), 9);
let padded = pad_name_to_display_width(truncated, 10);
assert_eq!(padded.width(), 10);
assert_eq!(padded, ".../f再见 ");
}
#[test]
fn diffstat_name_width_10_matches_git_padding() {
let files = vec![FileStatInput {
path_display: "d你好/f再见".to_string(),
insertions: 0,
deletions: 0,
is_binary: false,
}];
let opts = DiffstatOptions {
total_width: 80,
line_prefix: "",
subtract_prefix_from_terminal: false,
stat_name_width: Some(10),
stat_graph_width: None,
stat_count: None,
color_add: "",
color_del: "",
color_reset: "",
graph_bar_slack: 0,
graph_prefix_budget_slack: 0,
};
let mut buf = Vec::new();
write_diffstat_block(&mut buf, &files, &opts).unwrap();
let s = String::from_utf8(buf).unwrap();
let line = s.lines().next().unwrap();
assert!(
line.contains(".../f再见 |"),
"expected two spaces before pipe like git, got {line:?}"
);
}
}