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).
118fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
119    let full_w = path.width();
120    if full_w <= area_width {
121        return (path.to_string(), full_w);
122    }
123    let mut len = area_width;
124    len = len.saturating_sub(3);
125    let mut byte_start = 0usize;
126    let mut name_w = full_w;
127    while name_w > len {
128        let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
129        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
130        name_w = name_w.saturating_sub(cw);
131        byte_start += ch.len_utf8();
132    }
133    let rest = &path[byte_start..];
134    if let Some(slash_idx) = rest.find('/') {
135        let after = &rest[slash_idx..];
136        let after_w = after.width();
137        if after_w <= area_width {
138            return (format!("...{}", after), after_w);
139        }
140    }
141    let s = format!("...{}", rest);
142    (s.clone(), s.width())
143}
144
145/// Write diffstat lines and summary, matching Git's layout.
146pub fn write_diffstat_block(
147    out: &mut impl Write,
148    files: &[FileStatInput],
149    opts: &DiffstatOptions<'_>,
150) -> IoResult<()> {
151    if files.is_empty() {
152        return Ok(());
153    }
154
155    let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
156    let shown = &files[..limit];
157
158    let mut max_len = 0usize;
159    let mut max_change = 0usize;
160    let mut number_width = 0usize;
161    let mut bin_width = 0usize;
162
163    for f in shown {
164        let w = f.path_display.width();
165        if max_len < w {
166            max_len = w;
167        }
168        if f.is_binary {
169            let w = 14 + decimal_width(f.insertions) + decimal_width(f.deletions);
170            if bin_width < w {
171                bin_width = w;
172            }
173            number_width = number_width.max(3);
174            continue;
175        }
176        let ch = f.insertions + f.deletions;
177        if max_change < ch {
178            max_change = ch;
179        }
180    }
181
182    let mut width = if opts.subtract_prefix_from_terminal {
183        terminal_columns()
184            .saturating_sub(display_width_minus_ansi(opts.line_prefix))
185            .saturating_add(opts.graph_prefix_budget_slack)
186    } else {
187        opts.total_width
188    };
189
190    number_width = number_width.max(decimal_width(max_change));
191
192    if width < 16 + 6 + number_width {
193        width = 16 + 6 + number_width;
194    }
195
196    let mut graph_width = if max_change + 4 > bin_width {
197        max_change
198    } else {
199        bin_width.saturating_sub(4)
200    };
201    if let Some(cap) = opts.stat_graph_width {
202        if cap > 0 && cap < graph_width {
203            graph_width = cap;
204        }
205    }
206
207    let mut name_width = match opts.stat_name_width {
208        Some(nw) if nw > 0 && nw < max_len => nw,
209        _ => max_len,
210    };
211
212    if name_width + number_width + 6 + graph_width > width {
213        let mut gw = graph_width;
214        let target_gw = width * 3 / 8;
215        if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
216            gw = target_gw.saturating_sub(number_width).saturating_sub(6);
217            if gw < 6 {
218                gw = 6;
219            }
220        }
221        graph_width = gw;
222        if let Some(cap) = opts.stat_graph_width {
223            if graph_width > cap {
224                graph_width = cap;
225            }
226        }
227        if name_width
228            > width
229                .saturating_sub(number_width)
230                .saturating_sub(6)
231                .saturating_sub(graph_width)
232        {
233            name_width = width
234                .saturating_sub(number_width)
235                .saturating_sub(6)
236                .saturating_sub(graph_width);
237        } else {
238            graph_width = width
239                .saturating_sub(number_width)
240                .saturating_sub(6)
241                .saturating_sub(name_width);
242        }
243    }
244
245    graph_width = graph_width.saturating_add(opts.graph_bar_slack);
246
247    let mut total_ins = 0usize;
248    let mut total_del = 0usize;
249
250    for f in shown {
251        let prefix = opts.line_prefix;
252        if f.is_binary {
253            let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
254            if prefix.is_empty() {
255                writeln!(
256                    out,
257                    " {:<nw_name$} | {:>nw$} {} -> {} bytes",
258                    display_name,
259                    "Bin",
260                    f.deletions,
261                    f.insertions,
262                    nw_name = name_width,
263                    nw = number_width
264                )?;
265            } else {
266                writeln!(
267                    out,
268                    "{prefix}{:<nw_name$} | {:>nw$} {} -> {} bytes",
269                    display_name,
270                    "Bin",
271                    f.deletions,
272                    f.insertions,
273                    nw_name = name_width,
274                    nw = number_width
275                )?;
276            }
277            continue;
278        }
279
280        let added = f.insertions;
281        let deleted = f.deletions;
282        let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
283
284        let mut add = added;
285        let mut del = deleted;
286        if graph_width <= max_change && max_change > 0 {
287            let total_scaled = scale_linear(added + del, graph_width, max_change);
288            let mut total = total_scaled;
289            if total < 2 && add > 0 && del > 0 {
290                total = 2;
291            }
292            if add < del {
293                add = scale_linear(add, graph_width, max_change);
294                del = total.saturating_sub(add);
295            } else {
296                del = scale_linear(del, graph_width, max_change);
297                add = total.saturating_sub(del);
298            }
299        }
300
301        total_ins = total_ins.saturating_add(added);
302        total_del = total_del.saturating_add(deleted);
303
304        let total = added + del;
305        if prefix.is_empty() {
306            write!(
307                out,
308                " {:<nw_name$} | {:>nw$}",
309                display_name,
310                total,
311                nw_name = name_width,
312                nw = number_width
313            )?;
314        } else {
315            write!(
316                out,
317                "{prefix}{:<nw_name$} | {:>nw$}",
318                display_name,
319                total,
320                nw_name = name_width,
321                nw = number_width
322            )?;
323        }
324        if total > 0 {
325            write!(out, " ")?;
326        }
327        if add > 0 {
328            if !opts.color_add.is_empty() {
329                write!(out, "{}", opts.color_add)?;
330            }
331            write!(out, "{}", "+".repeat(add))?;
332            if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
333                write!(out, "{}", opts.color_reset)?;
334            }
335        }
336        if del > 0 {
337            if !opts.color_del.is_empty() {
338                write!(out, "{}", opts.color_del)?;
339            }
340            write!(out, "{}", "-".repeat(del))?;
341            if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
342                write!(out, "{}", opts.color_reset)?;
343            }
344        }
345        writeln!(out)?;
346    }
347
348    if files.len() > limit {
349        if opts.line_prefix.is_empty() {
350            writeln!(out, " ...")?;
351        } else {
352            writeln!(out, "{}...", opts.line_prefix)?;
353        }
354    }
355
356    let files_changed = files.len();
357    let mut summary = if opts.line_prefix.is_empty() {
358        format!(
359            " {} file{} changed",
360            files_changed,
361            if files_changed == 1 { "" } else { "s" }
362        )
363    } else {
364        format!(
365            "{}{} file{} changed",
366            opts.line_prefix,
367            files_changed,
368            if files_changed == 1 { "" } else { "s" }
369        )
370    };
371    if total_ins > 0 {
372        summary.push_str(&format!(
373            ", {} insertion{}(+)",
374            total_ins,
375            if total_ins == 1 { "" } else { "s" }
376        ));
377    }
378    if total_del > 0 {
379        summary.push_str(&format!(
380            ", {} deletion{}(-)",
381            total_del,
382            if total_del == 1 { "" } else { "s" }
383        ));
384    }
385    if total_ins == 0 && total_del == 0 {
386        summary.push_str(", 0 insertions(+), 0 deletions(-)");
387    }
388    writeln!(out, "{summary}")?;
389
390    Ok(())
391}