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