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    /// Unmerged (conflicted) path: rendered as ` name | Unmerged` and excluded
70    /// from the "N files changed" count (git `diffstat_file.is_unmerged`).
71    pub is_unmerged: bool,
72}
73
74/// Options for laying out diffstat lines (Git `diff_options` stat fields).
75#[derive(Debug, Clone)]
76pub struct DiffstatOptions<'a> {
77    /// Total display width for the stat block (after subtracting `line_prefix` when using terminal width).
78    pub total_width: usize,
79    /// Prefix printed before each stat line (graph + color); only affects width budget when
80    /// `subtract_prefix_from_terminal` is true.
81    pub line_prefix: &'a str,
82    /// When true, width budget is `terminal_columns() - display_width_minus_ansi(line_prefix)`.
83    pub subtract_prefix_from_terminal: bool,
84    /// Cap filename area (`diff.statNameWidth` / `--stat-name-width`).
85    pub stat_name_width: Option<usize>,
86    /// Cap graph (+/-) area (`diff.statGraphWidth` / `--stat-graph-width`).
87    pub stat_graph_width: Option<usize>,
88    /// Max files to show; extra files omitted with a `...` line.
89    pub stat_count: Option<usize>,
90    /// ANSI SGR before `+` run (empty = no color).
91    pub color_add: &'a str,
92    /// ANSI SGR before `-` run (empty = no color).
93    pub color_del: &'a str,
94    /// ANSI reset after colored bar segments (typically `\x1b[m`).
95    pub color_reset: &'a str,
96    /// Extra columns allocated to the +/- bar (Git `log --graph --stat` uses one more than plain diffstat).
97    pub graph_bar_slack: usize,
98    /// When subtracting `line_prefix` from `COLUMNS`, add this many columns back (colored graph `|`).
99    pub graph_prefix_budget_slack: usize,
100}
101
102fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
103    if it == 0 || max_change == 0 {
104        return 0;
105    }
106    if width <= 1 {
107        return if it > 0 { 1 } else { 0 };
108    }
109    1 + (it * (width - 1) / max_change)
110}
111
112fn decimal_width(n: usize) -> usize {
113    if n == 0 {
114        1
115    } else {
116        format!("{n}").len()
117    }
118}
119
120/// Truncate a path to fit `area_width` display columns (Git `show_stats` name scaling).
121/// Pad `s` with trailing ASCII spaces so its display width is at least `min_cols`.
122///
123/// Git's diffstat uses display-column width for the name field (`utf8_strnwidth`-style), not
124/// Rust's `{:<n$}` padding which counts Unicode scalar values.
125fn pad_name_to_display_width(s: &str, min_cols: usize) -> String {
126    let w = s.width();
127    if w >= min_cols {
128        return s.to_string();
129    }
130    let pad = min_cols - w;
131    let mut out = String::with_capacity(s.len() + pad);
132    out.push_str(s);
133    out.push_str(&" ".repeat(pad));
134    out
135}
136
137fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
138    let full_w = path.width();
139    if full_w <= area_width {
140        return (path.to_string(), full_w);
141    }
142    let mut len = area_width;
143    len = len.saturating_sub(3);
144    let mut byte_start = 0usize;
145    let mut name_w = full_w;
146    while name_w > len {
147        let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
148        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
149        name_w = name_w.saturating_sub(cw);
150        byte_start += ch.len_utf8();
151    }
152    let rest = &path[byte_start..];
153    if let Some(slash_idx) = rest.find('/') {
154        let after = &rest[slash_idx..];
155        let after_w = after.width();
156        if after_w <= area_width {
157            return (format!("...{}", after), after_w);
158        }
159    }
160    let s = format!("...{}", rest);
161    (s.clone(), s.width())
162}
163
164/// Write diffstat lines and summary, matching Git's layout.
165pub fn write_diffstat_block(
166    out: &mut impl Write,
167    files: &[FileStatInput],
168    opts: &DiffstatOptions<'_>,
169) -> IoResult<()> {
170    if files.is_empty() {
171        return Ok(());
172    }
173
174    let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
175    let shown = &files[..limit];
176
177    let mut max_len = 0usize;
178    let mut max_change = 0usize;
179    let mut number_width = 0usize;
180    let mut bin_width = 0usize;
181
182    for f in shown {
183        let w = f.path_display.width();
184        if max_len < w {
185            max_len = w;
186        }
187        if f.is_unmerged {
188            // "Unmerged" is 8 characters (git show_stats()).
189            if bin_width < 8 {
190                bin_width = 8;
191            }
192            continue;
193        }
194        if f.is_binary {
195            let w = if f.insertions == 0 && f.deletions == 0 {
196                3
197            } else {
198                14 + decimal_width(f.insertions) + decimal_width(f.deletions)
199            };
200            if bin_width < w {
201                bin_width = w;
202            }
203            number_width = number_width.max(3);
204            continue;
205        }
206        let ch = f.insertions + f.deletions;
207        if max_change < ch {
208            max_change = ch;
209        }
210    }
211
212    let mut width = if opts.subtract_prefix_from_terminal {
213        terminal_columns()
214            .saturating_sub(display_width_minus_ansi(opts.line_prefix))
215            .saturating_add(opts.graph_prefix_budget_slack)
216    } else {
217        opts.total_width
218    };
219
220    number_width = number_width.max(decimal_width(max_change));
221
222    if width < 16 + 6 + number_width {
223        width = 16 + 6 + number_width;
224    }
225
226    let mut graph_width = if max_change + 4 > bin_width {
227        max_change
228    } else {
229        bin_width.saturating_sub(4)
230    };
231    if let Some(cap) = opts.stat_graph_width {
232        if cap > 0 && cap < graph_width {
233            graph_width = cap;
234        }
235    }
236
237    let mut name_width = match opts.stat_name_width {
238        Some(nw) if nw > 0 && nw < max_len => nw,
239        _ => max_len,
240    };
241
242    if name_width + number_width + 6 + graph_width > width {
243        let mut gw = graph_width;
244        let target_gw = width * 3 / 8;
245        if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
246            gw = target_gw.saturating_sub(number_width).saturating_sub(6);
247            if gw < 6 {
248                gw = 6;
249            }
250        }
251        graph_width = gw;
252        if let Some(cap) = opts.stat_graph_width {
253            if graph_width > cap {
254                graph_width = cap;
255            }
256        }
257        if name_width
258            > width
259                .saturating_sub(number_width)
260                .saturating_sub(6)
261                .saturating_sub(graph_width)
262        {
263            name_width = width
264                .saturating_sub(number_width)
265                .saturating_sub(6)
266                .saturating_sub(graph_width);
267        } else {
268            graph_width = width
269                .saturating_sub(number_width)
270                .saturating_sub(6)
271                .saturating_sub(name_width);
272        }
273    }
274
275    graph_width = graph_width.saturating_add(opts.graph_bar_slack);
276
277    let mut total_ins = 0usize;
278    let mut total_del = 0usize;
279
280    for f in shown {
281        let prefix = opts.line_prefix;
282        if f.is_unmerged {
283            let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
284            let name_col = pad_name_to_display_width(&display_name, name_width);
285            // git: ` %s%s%*s | %*sUnmerged` — number_width is usually < len("Unmerged"),
286            // so the word is printed verbatim with no extra left padding.
287            if prefix.is_empty() {
288                writeln!(
289                    out,
290                    " {} | {:>nw$}",
291                    name_col,
292                    "Unmerged",
293                    nw = number_width
294                )?;
295            } else {
296                writeln!(
297                    out,
298                    "{prefix}{} | {:>nw$}",
299                    name_col,
300                    "Unmerged",
301                    nw = number_width
302                )?;
303            }
304            continue;
305        }
306        if f.is_binary {
307            let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
308            let name_col = pad_name_to_display_width(&display_name, name_width);
309            if f.insertions == 0 && f.deletions == 0 {
310                if prefix.is_empty() {
311                    writeln!(out, " {} | {:>nw$}", name_col, "Bin", nw = number_width)?;
312                } else {
313                    writeln!(
314                        out,
315                        "{prefix}{} | {:>nw$}",
316                        name_col,
317                        "Bin",
318                        nw = number_width
319                    )?;
320                }
321            } else if prefix.is_empty() {
322                writeln!(
323                    out,
324                    " {} | {:>nw$} {} -> {} bytes",
325                    name_col,
326                    "Bin",
327                    f.deletions,
328                    f.insertions,
329                    nw = number_width
330                )?;
331            } else {
332                writeln!(
333                    out,
334                    "{prefix}{} | {:>nw$} {} -> {} bytes",
335                    name_col,
336                    "Bin",
337                    f.deletions,
338                    f.insertions,
339                    nw = number_width
340                )?;
341            }
342            continue;
343        }
344
345        let added = f.insertions;
346        let deleted = f.deletions;
347        let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
348        let name_col = pad_name_to_display_width(&display_name, name_width);
349
350        let mut add = added;
351        let mut del = deleted;
352        if graph_width <= max_change && max_change > 0 {
353            let total_scaled = scale_linear(added + del, graph_width, max_change);
354            let mut total = total_scaled;
355            if total < 2 && add > 0 && del > 0 {
356                total = 2;
357            }
358            if add < del {
359                add = scale_linear(add, graph_width, max_change);
360                del = total.saturating_sub(add);
361            } else {
362                del = scale_linear(del, graph_width, max_change);
363                add = total.saturating_sub(del);
364            }
365        }
366
367        total_ins = total_ins.saturating_add(added);
368        total_del = total_del.saturating_add(deleted);
369
370        let total = added + del;
371        if prefix.is_empty() {
372            write!(out, " {} | {:>nw$}", name_col, total, nw = number_width)?;
373        } else {
374            write!(
375                out,
376                "{prefix}{} | {:>nw$}",
377                name_col,
378                total,
379                nw = number_width
380            )?;
381        }
382        if total > 0 {
383            write!(out, " ")?;
384        }
385        if add > 0 {
386            if !opts.color_add.is_empty() {
387                write!(out, "{}", opts.color_add)?;
388            }
389            write!(out, "{}", "+".repeat(add))?;
390            if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
391                write!(out, "{}", opts.color_reset)?;
392            }
393        }
394        if del > 0 {
395            if !opts.color_del.is_empty() {
396                write!(out, "{}", opts.color_del)?;
397            }
398            write!(out, "{}", "-".repeat(del))?;
399            if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
400                write!(out, "{}", opts.color_reset)?;
401            }
402        }
403        writeln!(out)?;
404    }
405
406    if files.len() > limit {
407        if opts.line_prefix.is_empty() {
408            writeln!(out, " ...")?;
409        } else {
410            writeln!(out, "{}...", opts.line_prefix)?;
411        }
412    }
413
414    // `--stat-count` only truncates the per-file lines; the summary still
415    // covers every entry (t4049).
416    for f in &files[limit..] {
417        if f.is_binary {
418            continue;
419        }
420        total_ins = total_ins.saturating_add(f.insertions);
421        total_del = total_del.saturating_add(f.deletions);
422    }
423
424    // Unmerged paths are listed but not counted as "changed" (git show_stats()).
425    let files_changed = files.iter().filter(|f| !f.is_unmerged).count();
426    let mut summary = if opts.line_prefix.is_empty() {
427        format!(
428            " {} file{} changed",
429            files_changed,
430            if files_changed == 1 { "" } else { "s" }
431        )
432    } else {
433        format!(
434            "{}{} file{} changed",
435            opts.line_prefix,
436            files_changed,
437            if files_changed == 1 { "" } else { "s" }
438        )
439    };
440    // git: when no files changed (e.g. only unmerged paths), the summary is just
441    // " 0 files changed" with no insertions/deletions suffix.
442    if files_changed > 0 {
443        if total_ins > 0 {
444            summary.push_str(&format!(
445                ", {} insertion{}(+)",
446                total_ins,
447                if total_ins == 1 { "" } else { "s" }
448            ));
449        }
450        if total_del > 0 {
451            summary.push_str(&format!(
452                ", {} deletion{}(-)",
453                total_del,
454                if total_del == 1 { "" } else { "s" }
455            ));
456        }
457        if total_ins == 0 && total_del == 0 {
458            summary.push_str(", 0 insertions(+), 0 deletions(-)");
459        }
460    }
461    writeln!(out, "{summary}")?;
462
463    Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn pad_name_matches_git_display_columns_for_wide_chars() {
472        // Truncated path from t4073: display width 9; Git pads to name_width 10 with one space.
473        let truncated = ".../f再见";
474        assert_eq!(truncated.width(), 9);
475        let padded = pad_name_to_display_width(truncated, 10);
476        assert_eq!(padded.width(), 10);
477        assert_eq!(padded, ".../f再见 ");
478    }
479
480    #[test]
481    fn diffstat_name_width_10_matches_git_padding() {
482        let files = vec![FileStatInput {
483            path_display: "d你好/f再见".to_string(),
484            insertions: 0,
485            deletions: 0,
486            is_binary: false,
487            is_unmerged: false,
488        }];
489        let opts = DiffstatOptions {
490            total_width: 80,
491            line_prefix: "",
492            subtract_prefix_from_terminal: false,
493            stat_name_width: Some(10),
494            stat_graph_width: None,
495            stat_count: None,
496            color_add: "",
497            color_del: "",
498            color_reset: "",
499            graph_bar_slack: 0,
500            graph_prefix_budget_slack: 0,
501        };
502        let mut buf = Vec::new();
503        write_diffstat_block(&mut buf, &files, &opts).unwrap();
504        let s = String::from_utf8(buf).unwrap();
505        let line = s.lines().next().unwrap();
506        assert!(
507            line.contains(".../f再见  |"),
508            "expected two spaces before pipe like git, got {line:?}"
509        );
510    }
511}