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