Skip to main content

grit_lib/
diffstat.rs

1//! Git-compatible `--stat` / diffstat layout (width, name truncation, bar scaling).
2//!
3//! Matches the width algorithm in Git's `show_stats()` (`diff.c`).
4
5use std::io::{Result as IoResult, Write};
6
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9/// Visible terminal width of `s`, skipping ANSI CSI sequences (like Git `utf8_strnwidth(..., 1)`).
10#[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/// `term_columns()` approximation: `COLUMNS` env, then `stty size`, then 80.
32#[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
60/// Default total width for `format-patch` diffstat (`MAIL_DEFAULT_WRAP` in Git).
61pub 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/// Options for laying out diffstat lines (Git `diff_options` stat fields).
72#[derive(Debug, Clone)]
73pub struct DiffstatOptions<'a> {
74    /// Total display width for the stat block (after subtracting `line_prefix` when using terminal width).
75    pub total_width: usize,
76    /// Prefix printed before each stat line (graph + color); only affects width budget when
77    /// `subtract_prefix_from_terminal` is true.
78    pub line_prefix: &'a str,
79    /// When true, width budget is `terminal_columns() - display_width_minus_ansi(line_prefix)`.
80    pub subtract_prefix_from_terminal: bool,
81    /// Cap filename area (`diff.statNameWidth` / `--stat-name-width`).
82    pub stat_name_width: Option<usize>,
83    /// Cap graph (+/-) area (`diff.statGraphWidth` / `--stat-graph-width`).
84    pub stat_graph_width: Option<usize>,
85    /// Max files to show; extra files omitted with a `...` line.
86    pub stat_count: Option<usize>,
87    /// ANSI SGR before `+` run (empty = no color).
88    pub color_add: &'a str,
89    /// ANSI SGR before `-` run (empty = no color).
90    pub color_del: &'a str,
91    /// ANSI reset after colored bar segments (typically `\x1b[m`).
92    pub color_reset: &'a str,
93    /// Extra columns allocated to the +/- bar (Git `log --graph --stat` uses one more than plain diffstat).
94    pub graph_bar_slack: usize,
95    /// When subtracting `line_prefix` from `COLUMNS`, add this many columns back (colored graph `|`).
96    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
117/// Truncate a path to fit `area_width` display columns (Git `show_stats` name scaling).
118/// Pad `s` with trailing ASCII spaces so its display width is at least `min_cols`.
119///
120/// Git's diffstat uses display-column width for the name field (`utf8_strnwidth`-style), not
121/// Rust's `{:<n$}` padding which counts Unicode scalar values.
122fn 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
161/// Write diffstat lines and summary, matching Git's layout.
162pub 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        // Truncated path from t4073: display width 9; Git pads to name_width 10 with one space.
408        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}