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#[derive(Debug, Clone)]
32/// `FileStatInput` struct.
33pub struct FileStatInput {
34    /// `path_display` field.
35    pub path_display: String,
36    /// `insertions` field.
37    pub insertions: usize,
38    /// `deletions` field.
39    pub deletions: usize,
40    /// `is_binary` field.
41    pub is_binary: bool,
42    /// Unmerged (conflicted) path: rendered as ` name | Unmerged` and excluded
43    /// from the "N files changed" count (git `diffstat_file.is_unmerged`).
44    pub is_unmerged: bool,
45}
46
47/// Options for laying out diffstat lines (Git `diff_options` stat fields).
48#[derive(Debug, Clone)]
49pub struct DiffstatOptions<'a> {
50    /// Total display width for the stat block (after subtracting `line_prefix` when using terminal width).
51    pub total_width: usize,
52    /// Prefix printed before each stat line (graph + color); only affects width budget when
53    /// `subtract_prefix_from_terminal` is true and `width_prefix` is empty.
54    pub line_prefix: &'a str,
55    /// Prefix whose display width is subtracted from the terminal columns for the width budget,
56    /// but which is *not* itself printed (the caller emits it separately, e.g. `log --graph`'s
57    /// per-line rail). When empty, `line_prefix` is used for the subtraction instead. Matches
58    /// Git's `width = term_columns() - utf8_strnwidth(line_prefix)` where the graph's vertical
59    /// rail is the `output_prefix`.
60    pub width_prefix: &'a str,
61    /// When true, width budget is [`Self::terminal_width`] minus
62    /// `display_width_minus_ansi(<prefix>)` (callers set [`Self::terminal_width`] from the
63    /// terminal size or `COLUMNS`, typically in `grit-cli`).
64    pub subtract_prefix_from_terminal: bool,
65    /// Terminal width in columns when [`Self::subtract_prefix_from_terminal`] is true.
66    pub terminal_width: usize,
67    /// Cap filename area (`diff.statNameWidth` / `--stat-name-width`).
68    pub stat_name_width: Option<usize>,
69    /// Cap graph (+/-) area (`diff.statGraphWidth` / `--stat-graph-width`).
70    pub stat_graph_width: Option<usize>,
71    /// Max files to show; extra files omitted with a `...` line.
72    pub stat_count: Option<usize>,
73    /// ANSI SGR before `+` run (empty = no color).
74    pub color_add: &'a str,
75    /// ANSI SGR before `-` run (empty = no color).
76    pub color_del: &'a str,
77    /// ANSI reset after colored bar segments (typically `\x1b[m`).
78    pub color_reset: &'a str,
79    /// Extra columns allocated to the +/- bar (Git `log --graph --stat` uses one more than plain diffstat).
80    pub graph_bar_slack: usize,
81    /// When subtracting `line_prefix` from `COLUMNS`, add this many columns back (colored graph `|`).
82    pub graph_prefix_budget_slack: usize,
83}
84
85fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
86    if it == 0 || max_change == 0 {
87        return 0;
88    }
89    if width <= 1 {
90        return if it > 0 { 1 } else { 0 };
91    }
92    1 + (it * (width - 1) / max_change)
93}
94
95fn decimal_width(n: usize) -> usize {
96    if n == 0 {
97        1
98    } else {
99        format!("{n}").len()
100    }
101}
102
103/// Truncate a path to fit `area_width` display columns (Git `show_stats` name scaling).
104/// Pad `s` with trailing ASCII spaces so its display width is at least `min_cols`.
105///
106/// Git's diffstat uses display-column width for the name field (`utf8_strnwidth`-style), not
107/// Rust's `{:<n$}` padding which counts Unicode scalar values.
108fn pad_name_to_display_width(s: &str, min_cols: usize) -> String {
109    let w = s.width();
110    if w >= min_cols {
111        return s.to_string();
112    }
113    let pad = min_cols - w;
114    let mut out = String::with_capacity(s.len() + pad);
115    out.push_str(s);
116    out.push_str(&" ".repeat(pad));
117    out
118}
119
120fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
121    let full_w = path.width();
122    if full_w <= area_width {
123        return (path.to_string(), full_w);
124    }
125    let mut len = area_width;
126    len = len.saturating_sub(3);
127    let mut byte_start = 0usize;
128    let mut name_w = full_w;
129    while name_w > len {
130        let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
131        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
132        name_w = name_w.saturating_sub(cw);
133        byte_start += ch.len_utf8();
134    }
135    let rest = &path[byte_start..];
136    if let Some(slash_idx) = rest.find('/') {
137        let after = &rest[slash_idx..];
138        let after_w = after.width();
139        if after_w <= area_width {
140            return (format!("...{}", after), after_w);
141        }
142    }
143    let s = format!("...{}", rest);
144    (s.clone(), s.width())
145}
146
147/// Format a single per-file `--stat` line at a fixed total width (Git `show_stats`).
148pub fn format_single_stat_line(
149    path: &str,
150    insertions: usize,
151    deletions: usize,
152    total_width: usize,
153) -> IoResult<String> {
154    let mut buf = Vec::new();
155    let files = [FileStatInput {
156        path_display: path.to_string(),
157        insertions,
158        deletions,
159        is_binary: false,
160        is_unmerged: false,
161    }];
162    let opts = DiffstatOptions {
163        terminal_width: total_width,
164        total_width,
165        stat_name_width: None,
166        stat_graph_width: None,
167        stat_count: Some(1),
168        line_prefix: "",
169        width_prefix: "",
170        subtract_prefix_from_terminal: false,
171        graph_prefix_budget_slack: 0,
172        color_add: "",
173        color_del: "",
174        color_reset: "",
175        graph_bar_slack: 0,
176    };
177    write_diffstat_block(&mut buf, &files, &opts)?;
178    let text = String::from_utf8(buf)
179        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
180    Ok(text.lines().next().unwrap_or("").trim_end().to_string())
181}
182
183/// Write diffstat lines and summary, matching Git's layout.
184pub fn write_diffstat_block(
185    out: &mut impl Write,
186    files: &[FileStatInput],
187    opts: &DiffstatOptions<'_>,
188) -> IoResult<()> {
189    if files.is_empty() {
190        return Ok(());
191    }
192
193    let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
194    let shown = &files[..limit];
195
196    let mut max_len = 0usize;
197    let mut max_change = 0usize;
198    let mut number_width = 0usize;
199    let mut bin_width = 0usize;
200
201    for f in shown {
202        let w = f.path_display.width();
203        if max_len < w {
204            max_len = w;
205        }
206        if f.is_unmerged {
207            // "Unmerged" is 8 characters (git show_stats()).
208            if bin_width < 8 {
209                bin_width = 8;
210            }
211            continue;
212        }
213        if f.is_binary {
214            let w = if f.insertions == 0 && f.deletions == 0 {
215                3
216            } else {
217                14 + decimal_width(f.insertions) + decimal_width(f.deletions)
218            };
219            if bin_width < w {
220                bin_width = w;
221            }
222            number_width = number_width.max(3);
223            continue;
224        }
225        let ch = f.insertions + f.deletions;
226        if max_change < ch {
227            max_change = ch;
228        }
229    }
230
231    let width_prefix = if opts.width_prefix.is_empty() {
232        opts.line_prefix
233    } else {
234        opts.width_prefix
235    };
236    let mut width = if opts.subtract_prefix_from_terminal {
237        opts.terminal_width
238            .saturating_sub(display_width_minus_ansi(width_prefix))
239            .saturating_add(opts.graph_prefix_budget_slack)
240    } else {
241        opts.total_width
242    };
243
244    number_width = number_width.max(decimal_width(max_change));
245
246    if width < 16 + 6 + number_width {
247        width = 16 + 6 + number_width;
248    }
249
250    let mut graph_width = if max_change + 4 > bin_width {
251        max_change
252    } else {
253        bin_width.saturating_sub(4)
254    };
255    if let Some(cap) = opts.stat_graph_width {
256        if cap > 0 && cap < graph_width {
257            graph_width = cap;
258        }
259    }
260
261    let mut name_width = match opts.stat_name_width {
262        Some(nw) if nw > 0 && nw < max_len => nw,
263        _ => max_len,
264    };
265
266    if name_width + number_width + 6 + graph_width > width {
267        let mut gw = graph_width;
268        let target_gw = width * 3 / 8;
269        if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
270            gw = target_gw.saturating_sub(number_width).saturating_sub(6);
271            if gw < 6 {
272                gw = 6;
273            }
274        }
275        graph_width = gw;
276        if let Some(cap) = opts.stat_graph_width {
277            if graph_width > cap {
278                graph_width = cap;
279            }
280        }
281        if name_width
282            > width
283                .saturating_sub(number_width)
284                .saturating_sub(6)
285                .saturating_sub(graph_width)
286        {
287            name_width = width
288                .saturating_sub(number_width)
289                .saturating_sub(6)
290                .saturating_sub(graph_width);
291        } else {
292            graph_width = width
293                .saturating_sub(number_width)
294                .saturating_sub(6)
295                .saturating_sub(name_width);
296        }
297    }
298
299    graph_width = graph_width.saturating_add(opts.graph_bar_slack);
300
301    let mut total_ins = 0usize;
302    let mut total_del = 0usize;
303
304    for f in shown {
305        let prefix = opts.line_prefix;
306        if f.is_unmerged {
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            // git: ` %s%s%*s | %*sUnmerged` — number_width is usually < len("Unmerged"),
310            // so the word is printed verbatim with no extra left padding.
311            if prefix.is_empty() {
312                writeln!(
313                    out,
314                    " {} | {:>nw$}",
315                    name_col,
316                    "Unmerged",
317                    nw = number_width
318                )?;
319            } else {
320                writeln!(
321                    out,
322                    "{prefix}{} | {:>nw$}",
323                    name_col,
324                    "Unmerged",
325                    nw = number_width
326                )?;
327            }
328            continue;
329        }
330        if f.is_binary {
331            let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
332            let name_col = pad_name_to_display_width(&display_name, name_width);
333            if f.insertions == 0 && f.deletions == 0 {
334                if prefix.is_empty() {
335                    writeln!(out, " {} | {:>nw$}", name_col, "Bin", nw = number_width)?;
336                } else {
337                    writeln!(
338                        out,
339                        "{prefix}{} | {:>nw$}",
340                        name_col,
341                        "Bin",
342                        nw = number_width
343                    )?;
344                }
345            } else if prefix.is_empty() {
346                writeln!(
347                    out,
348                    " {} | {:>nw$} {} -> {} bytes",
349                    name_col,
350                    "Bin",
351                    f.deletions,
352                    f.insertions,
353                    nw = number_width
354                )?;
355            } else {
356                writeln!(
357                    out,
358                    "{prefix}{} | {:>nw$} {} -> {} bytes",
359                    name_col,
360                    "Bin",
361                    f.deletions,
362                    f.insertions,
363                    nw = number_width
364                )?;
365            }
366            continue;
367        }
368
369        let added = f.insertions;
370        let deleted = f.deletions;
371        let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
372        let name_col = pad_name_to_display_width(&display_name, name_width);
373
374        let mut add = added;
375        let mut del = deleted;
376        if graph_width <= max_change && max_change > 0 {
377            let total_scaled = scale_linear(added + del, graph_width, max_change);
378            let mut total = total_scaled;
379            if total < 2 && add > 0 && del > 0 {
380                total = 2;
381            }
382            if add < del {
383                add = scale_linear(add, graph_width, max_change);
384                del = total.saturating_sub(add);
385            } else {
386                del = scale_linear(del, graph_width, max_change);
387                add = total.saturating_sub(del);
388            }
389        }
390
391        total_ins = total_ins.saturating_add(added);
392        total_del = total_del.saturating_add(deleted);
393
394        let total = added + del;
395        if prefix.is_empty() {
396            write!(out, " {} | {:>nw$}", name_col, total, nw = number_width)?;
397        } else {
398            write!(
399                out,
400                "{prefix}{} | {:>nw$}",
401                name_col,
402                total,
403                nw = number_width
404            )?;
405        }
406        if total > 0 {
407            write!(out, " ")?;
408        }
409        if add > 0 {
410            if !opts.color_add.is_empty() {
411                write!(out, "{}", opts.color_add)?;
412            }
413            write!(out, "{}", "+".repeat(add))?;
414            if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
415                write!(out, "{}", opts.color_reset)?;
416            }
417        }
418        if del > 0 {
419            if !opts.color_del.is_empty() {
420                write!(out, "{}", opts.color_del)?;
421            }
422            write!(out, "{}", "-".repeat(del))?;
423            if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
424                write!(out, "{}", opts.color_reset)?;
425            }
426        }
427        writeln!(out)?;
428    }
429
430    if files.len() > limit {
431        if opts.line_prefix.is_empty() {
432            writeln!(out, " ...")?;
433        } else {
434            writeln!(out, "{}...", opts.line_prefix)?;
435        }
436    }
437
438    // `--stat-count` only truncates the per-file lines; the summary still
439    // covers every entry (t4049).
440    for f in &files[limit..] {
441        if f.is_binary {
442            continue;
443        }
444        total_ins = total_ins.saturating_add(f.insertions);
445        total_del = total_del.saturating_add(f.deletions);
446    }
447
448    // Unmerged paths are listed but not counted as "changed" (git show_stats()).
449    let files_changed = files.iter().filter(|f| !f.is_unmerged).count();
450    let mut summary = if opts.line_prefix.is_empty() {
451        format!(
452            " {} file{} changed",
453            files_changed,
454            if files_changed == 1 { "" } else { "s" }
455        )
456    } else {
457        format!(
458            "{}{} file{} changed",
459            opts.line_prefix,
460            files_changed,
461            if files_changed == 1 { "" } else { "s" }
462        )
463    };
464    // git: when no files changed (e.g. only unmerged paths), the summary is just
465    // " 0 files changed" with no insertions/deletions suffix.
466    if files_changed > 0 {
467        if total_ins > 0 {
468            summary.push_str(&format!(
469                ", {} insertion{}(+)",
470                total_ins,
471                if total_ins == 1 { "" } else { "s" }
472            ));
473        }
474        if total_del > 0 {
475            summary.push_str(&format!(
476                ", {} deletion{}(-)",
477                total_del,
478                if total_del == 1 { "" } else { "s" }
479            ));
480        }
481        if total_ins == 0 && total_del == 0 {
482            summary.push_str(", 0 insertions(+), 0 deletions(-)");
483        }
484    }
485    writeln!(out, "{summary}")?;
486
487    Ok(())
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn pad_name_leaves_string_when_already_wide_enough() {
496        assert_eq!(pad_name_to_display_width("hello", 3), "hello");
497    }
498
499    #[test]
500    fn pad_name_matches_git_display_columns_for_wide_chars() {
501        // Truncated path from t4073: display width 9; Git pads to name_width 10 with one space.
502        let truncated = ".../f再见";
503        assert_eq!(truncated.width(), 9);
504        let padded = pad_name_to_display_width(truncated, 10);
505        assert_eq!(padded.width(), 10);
506        assert_eq!(padded, ".../f再见 ");
507    }
508
509    #[test]
510    fn display_width_minus_ansi_skips_csi_sequences() {
511        let s = "\x1b[31mhello\x1b[m";
512        assert_eq!(display_width_minus_ansi(s), "hello".width());
513    }
514
515    #[test]
516    fn write_diffstat_unmerged_binary_and_truncation() {
517        let files = vec![
518            FileStatInput {
519                path_display: "data.bin".into(),
520                insertions: 100,
521                deletions: 50,
522                is_binary: true,
523                is_unmerged: false,
524            },
525            FileStatInput {
526                path_display: "src/very/long/path/that/exceeds/the/name/area/width/file.rs".into(),
527                insertions: 3,
528                deletions: 1,
529                is_binary: false,
530                is_unmerged: false,
531            },
532            FileStatInput {
533                path_display: "conflict.txt".into(),
534                insertions: 0,
535                deletions: 0,
536                is_binary: false,
537                is_unmerged: true,
538            },
539            FileStatInput {
540                path_display: "empty.bin".into(),
541                insertions: 0,
542                deletions: 0,
543                is_binary: true,
544                is_unmerged: false,
545            },
546        ];
547        let opts = DiffstatOptions {
548            total_width: 60,
549            line_prefix: "| ",
550            width_prefix: "",
551            subtract_prefix_from_terminal: false,
552            terminal_width: 80,
553            stat_name_width: Some(20),
554            stat_graph_width: Some(8),
555            stat_count: Some(3),
556            color_add: "\x1b[32m",
557            color_del: "\x1b[31m",
558            color_reset: "\x1b[m",
559            graph_bar_slack: 1,
560            graph_prefix_budget_slack: 0,
561        };
562        let mut buf = Vec::new();
563        write_diffstat_block(&mut buf, &files, &opts).unwrap();
564        let s = String::from_utf8(buf).unwrap();
565        assert!(s.contains("Unmerged"));
566        assert!(s.contains("..."));
567        assert!(s.contains("bytes") || s.contains("Bin"));
568        assert!(s.contains("file"));
569        assert!(s.contains("changed"));
570    }
571
572    #[test]
573    fn write_diffstat_subtract_prefix_and_zero_change_summary() {
574        let files = vec![FileStatInput {
575            path_display: "only-unmerged".into(),
576            insertions: 0,
577            deletions: 0,
578            is_binary: false,
579            is_unmerged: true,
580        }];
581        let opts = DiffstatOptions {
582            total_width: 80,
583            line_prefix: "",
584            width_prefix: "graph ",
585            subtract_prefix_from_terminal: true,
586            terminal_width: 40,
587            stat_name_width: None,
588            stat_graph_width: None,
589            stat_count: None,
590            color_add: "",
591            color_del: "",
592            color_reset: "",
593            graph_bar_slack: 0,
594            graph_prefix_budget_slack: 2,
595        };
596        let mut buf = Vec::new();
597        write_diffstat_block(&mut buf, &files, &opts).unwrap();
598        let s = String::from_utf8(buf).unwrap();
599        assert!(s.contains("0 files changed"));
600    }
601
602    #[test]
603    fn write_diffstat_mixed_add_del_scales_graph() {
604        let files = vec![FileStatInput {
605            path_display: "x.txt".into(),
606            insertions: 40,
607            deletions: 10,
608            is_binary: false,
609            is_unmerged: false,
610        }];
611        let opts = DiffstatOptions {
612            total_width: 80,
613            line_prefix: "",
614            width_prefix: "",
615            subtract_prefix_from_terminal: false,
616            terminal_width: 80,
617            stat_name_width: None,
618            stat_graph_width: Some(6),
619            stat_count: None,
620            color_add: "",
621            color_del: "",
622            color_reset: "",
623            graph_bar_slack: 0,
624            graph_prefix_budget_slack: 0,
625        };
626        let mut buf = Vec::new();
627        write_diffstat_block(&mut buf, &files, &opts).unwrap();
628        let s = String::from_utf8(buf).unwrap();
629        let line = s.lines().next().expect("stat line");
630        assert!(line.contains('+'));
631    }
632
633    #[test]
634    fn diffstat_name_width_10_matches_git_padding() {
635        let files = vec![FileStatInput {
636            path_display: "d你好/f再见".to_string(),
637            insertions: 0,
638            deletions: 0,
639            is_binary: false,
640            is_unmerged: false,
641        }];
642        let opts = DiffstatOptions {
643            total_width: 80,
644            line_prefix: "",
645            width_prefix: "",
646            subtract_prefix_from_terminal: false,
647            terminal_width: 80,
648            stat_name_width: Some(10),
649            stat_graph_width: None,
650            stat_count: None,
651            color_add: "",
652            color_del: "",
653            color_reset: "",
654            graph_bar_slack: 0,
655            graph_prefix_budget_slack: 0,
656        };
657        let mut buf = Vec::new();
658        write_diffstat_block(&mut buf, &files, &opts).unwrap();
659        let s = String::from_utf8(buf).unwrap();
660        let line = s.lines().next().unwrap();
661        assert!(
662            line.contains(".../f再见  |"),
663            "expected two spaces before pipe like git, got {line:?}"
664        );
665    }
666
667    #[test]
668    fn write_diffstat_empty_files_is_noop() {
669        let opts = DiffstatOptions {
670            total_width: 80,
671            line_prefix: "",
672            width_prefix: "",
673            subtract_prefix_from_terminal: false,
674            terminal_width: 80,
675            stat_name_width: None,
676            stat_graph_width: None,
677            stat_count: None,
678            color_add: "",
679            color_del: "",
680            color_reset: "",
681            graph_bar_slack: 0,
682            graph_prefix_budget_slack: 0,
683        };
684        let mut buf = Vec::new();
685        write_diffstat_block(&mut buf, &[], &opts).unwrap();
686        assert!(buf.is_empty());
687    }
688
689    #[test]
690    fn truncate_path_prefers_suffix_after_slash() {
691        let long = "aaaa/bbbb/cccc/dddd/eeee/ffff/gggg/hhhh/iiii/jjjj/kkkk/llll/mmmm/nnnn/oooo/pppp/qqqq/rrrr/ssss/tttt/uuuu/vvvv/wwww/xxxx/yyyy/zzzz/final.rs";
692        let (display, w) = truncate_path_for_name_area(long, 24);
693        assert!(display.starts_with("..."));
694        assert!(display.contains("final.rs") || display.contains('/'));
695        assert!(w <= 24);
696    }
697
698    #[test]
699    fn write_diffstat_deletions_heavier_than_insertions() {
700        let files = vec![FileStatInput {
701            path_display: "y.txt".into(),
702            insertions: 2,
703            deletions: 50,
704            is_binary: false,
705            is_unmerged: false,
706        }];
707        let opts = DiffstatOptions {
708            total_width: 80,
709            line_prefix: "",
710            width_prefix: "",
711            subtract_prefix_from_terminal: false,
712            terminal_width: 80,
713            stat_name_width: None,
714            stat_graph_width: Some(10),
715            stat_count: None,
716            color_add: "",
717            color_del: "",
718            color_reset: "",
719            graph_bar_slack: 0,
720            graph_prefix_budget_slack: 0,
721        };
722        let mut buf = Vec::new();
723        write_diffstat_block(&mut buf, &files, &opts).unwrap();
724        let s = String::from_utf8(buf).unwrap();
725        assert!(s.contains('-'));
726        assert!(s.contains("deletion"));
727    }
728
729    #[test]
730    fn write_diffstat_zero_line_changes_non_binary() {
731        let files = vec![FileStatInput {
732            path_display: "touch.txt".into(),
733            insertions: 0,
734            deletions: 0,
735            is_binary: false,
736            is_unmerged: false,
737        }];
738        let opts = DiffstatOptions {
739            total_width: 80,
740            line_prefix: "",
741            width_prefix: "",
742            subtract_prefix_from_terminal: false,
743            terminal_width: 80,
744            stat_name_width: None,
745            stat_graph_width: None,
746            stat_count: None,
747            color_add: "",
748            color_del: "",
749            color_reset: "",
750            graph_bar_slack: 0,
751            graph_prefix_budget_slack: 0,
752        };
753        let mut buf = Vec::new();
754        write_diffstat_block(&mut buf, &files, &opts).unwrap();
755        let s = String::from_utf8(buf).unwrap();
756        assert!(s.contains("0 insertions(+), 0 deletions(-)"));
757    }
758
759    #[test]
760    fn write_diffstat_stat_count_hides_lines_but_sums_summary() {
761        let files = vec![
762            FileStatInput {
763                path_display: "a.txt".into(),
764                insertions: 5,
765                deletions: 0,
766                is_binary: false,
767                is_unmerged: false,
768            },
769            FileStatInput {
770                path_display: "b.txt".into(),
771                insertions: 0,
772                deletions: 7,
773                is_binary: false,
774                is_unmerged: false,
775            },
776        ];
777        let opts = DiffstatOptions {
778            total_width: 80,
779            line_prefix: "P",
780            width_prefix: "",
781            subtract_prefix_from_terminal: false,
782            terminal_width: 80,
783            stat_name_width: None,
784            stat_graph_width: None,
785            stat_count: Some(1),
786            color_add: "",
787            color_del: "",
788            color_reset: "",
789            graph_bar_slack: 0,
790            graph_prefix_budget_slack: 0,
791        };
792        let mut buf = Vec::new();
793        write_diffstat_block(&mut buf, &files, &opts).unwrap();
794        let s = String::from_utf8(buf).unwrap();
795        assert!(s.contains("P..."));
796        assert!(s.contains("2 files changed"));
797        assert!(s.contains("5 insertion"));
798        assert!(s.contains("7 deletion"));
799    }
800
801    #[test]
802    fn display_width_minus_ansi_bare_escape_is_visible() {
803        assert_eq!(display_width_minus_ansi("\x1bX"), 1);
804    }
805
806    #[test]
807    fn write_diffstat_narrow_total_width_still_renders() {
808        let files = vec![FileStatInput {
809            path_display: "big-change.txt".into(),
810            insertions: 500,
811            deletions: 500,
812            is_binary: false,
813            is_unmerged: false,
814        }];
815        let opts = DiffstatOptions {
816            total_width: 28,
817            line_prefix: "",
818            width_prefix: "",
819            subtract_prefix_from_terminal: false,
820            terminal_width: 80,
821            stat_name_width: None,
822            stat_graph_width: None,
823            stat_count: None,
824            color_add: "\x1b[32m",
825            color_del: "\x1b[31m",
826            color_reset: "\x1b[m",
827            graph_bar_slack: 0,
828            graph_prefix_budget_slack: 0,
829        };
830        let mut buf = Vec::new();
831        write_diffstat_block(&mut buf, &files, &opts).unwrap();
832        let s = String::from_utf8(buf).unwrap();
833        assert!(s.contains("file changed"));
834        assert!(s.contains("insertion"));
835        assert!(s.contains("deletion"));
836    }
837
838    #[test]
839    fn write_diffstat_summary_uses_line_prefix() {
840        let files = vec![FileStatInput {
841            path_display: "a.txt".into(),
842            insertions: 2,
843            deletions: 0,
844            is_binary: false,
845            is_unmerged: false,
846        }];
847        let opts = DiffstatOptions {
848            total_width: 80,
849            line_prefix: ">> ",
850            width_prefix: "",
851            subtract_prefix_from_terminal: false,
852            terminal_width: 80,
853            stat_name_width: None,
854            stat_graph_width: None,
855            stat_count: None,
856            color_add: "",
857            color_del: "",
858            color_reset: "",
859            graph_bar_slack: 0,
860            graph_prefix_budget_slack: 0,
861        };
862        let mut buf = Vec::new();
863        write_diffstat_block(&mut buf, &files, &opts).unwrap();
864        let s = String::from_utf8(buf).unwrap();
865        assert!(s.contains(">> 1 file changed"));
866        assert!(s.contains("2 insertion"));
867    }
868
869    #[test]
870    fn write_diffstat_binary_bin_only_with_prefix() {
871        let files = vec![FileStatInput {
872            path_display: "x.bin".into(),
873            insertions: 0,
874            deletions: 0,
875            is_binary: true,
876            is_unmerged: false,
877        }];
878        let opts = DiffstatOptions {
879            total_width: 60,
880            line_prefix: "> ",
881            width_prefix: "",
882            subtract_prefix_from_terminal: false,
883            terminal_width: 80,
884            stat_name_width: None,
885            stat_graph_width: None,
886            stat_count: None,
887            color_add: "",
888            color_del: "",
889            color_reset: "",
890            graph_bar_slack: 0,
891            graph_prefix_budget_slack: 0,
892        };
893        let mut buf = Vec::new();
894        write_diffstat_block(&mut buf, &files, &opts).unwrap();
895        let s = String::from_utf8(buf).unwrap();
896        assert!(s.contains("> "));
897        assert!(s.contains("Bin"));
898    }
899}