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    // The terminal size is constant for the life of the process (matching
42    // C git, which caches `term_columns()` after the first call); spawning
43    // `stty` once per --stat commit dominated history walks. The `COLUMNS`
44    // check above stays uncached so per-call env overrides keep working.
45    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
69/// Default total width for `format-patch` diffstat (`MAIL_DEFAULT_WRAP` in Git).
70pub 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    /// Unmerged (conflicted) path: rendered as ` name | Unmerged` and excluded
79    /// from the "N files changed" count (git `diffstat_file.is_unmerged`).
80    pub is_unmerged: bool,
81}
82
83/// Options for laying out diffstat lines (Git `diff_options` stat fields).
84#[derive(Debug, Clone)]
85pub struct DiffstatOptions<'a> {
86    /// Total display width for the stat block (after subtracting `line_prefix` when using terminal width).
87    pub total_width: usize,
88    /// Prefix printed before each stat line (graph + color); only affects width budget when
89    /// `subtract_prefix_from_terminal` is true and `width_prefix` is empty.
90    pub line_prefix: &'a str,
91    /// Prefix whose display width is subtracted from the terminal columns for the width budget,
92    /// but which is *not* itself printed (the caller emits it separately, e.g. `log --graph`'s
93    /// per-line rail). When empty, `line_prefix` is used for the subtraction instead. Matches
94    /// Git's `width = term_columns() - utf8_strnwidth(line_prefix)` where the graph's vertical
95    /// rail is the `output_prefix`.
96    pub width_prefix: &'a str,
97    /// When true, width budget is `terminal_columns() - display_width_minus_ansi(<prefix>)`.
98    pub subtract_prefix_from_terminal: bool,
99    /// Cap filename area (`diff.statNameWidth` / `--stat-name-width`).
100    pub stat_name_width: Option<usize>,
101    /// Cap graph (+/-) area (`diff.statGraphWidth` / `--stat-graph-width`).
102    pub stat_graph_width: Option<usize>,
103    /// Max files to show; extra files omitted with a `...` line.
104    pub stat_count: Option<usize>,
105    /// ANSI SGR before `+` run (empty = no color).
106    pub color_add: &'a str,
107    /// ANSI SGR before `-` run (empty = no color).
108    pub color_del: &'a str,
109    /// ANSI reset after colored bar segments (typically `\x1b[m`).
110    pub color_reset: &'a str,
111    /// Extra columns allocated to the +/- bar (Git `log --graph --stat` uses one more than plain diffstat).
112    pub graph_bar_slack: usize,
113    /// When subtracting `line_prefix` from `COLUMNS`, add this many columns back (colored graph `|`).
114    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
135/// Truncate a path to fit `area_width` display columns (Git `show_stats` name scaling).
136/// Pad `s` with trailing ASCII spaces so its display width is at least `min_cols`.
137///
138/// Git's diffstat uses display-column width for the name field (`utf8_strnwidth`-style), not
139/// Rust's `{:<n$}` padding which counts Unicode scalar values.
140fn 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
179/// Write diffstat lines and summary, matching Git's layout.
180pub 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            // "Unmerged" is 8 characters (git show_stats()).
204            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            // git: ` %s%s%*s | %*sUnmerged` — number_width is usually < len("Unmerged"),
306            // so the word is printed verbatim with no extra left padding.
307            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    // `--stat-count` only truncates the per-file lines; the summary still
435    // covers every entry (t4049).
436    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    // Unmerged paths are listed but not counted as "changed" (git show_stats()).
445    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    // git: when no files changed (e.g. only unmerged paths), the summary is just
461    // " 0 files changed" with no insertions/deletions suffix.
462    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        // Truncated path from t4073: display width 9; Git pads to name_width 10 with one space.
493        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}