Skip to main content

grit_lib/diff/
stat.rs

1//! Diffstat model: per-file line counts, totals, and change-bar scaling.
2//!
3//! [`DiffStat`] is the data behind `--stat`, `--shortstat` and `--numstat`
4//! style summaries. Callers build it from listed file diffs (or from their own
5//! [`FileStat`] rows) and render it however they like; [`LineStat::bar`]
6//! scales each row's `+`/`-` bar against the largest change in the stat.
7
8use std::iter::Sum;
9use std::ops::{Add, AddAssign};
10
11use super::detail::{DiffTotals, FileDiff, LineStat};
12use super::structured::{StructuredFileDiff, StructuredHunk, StructuredLineKind};
13use super::{DiffStatus, WhitespaceMode};
14use crate::combined_diff_patch::CombinedStructuredTextDiff;
15
16impl LineStat {
17    /// Total changed lines (`insertions + deletions`).
18    #[must_use]
19    pub fn changes(self) -> usize {
20        self.insertions + self.deletions
21    }
22
23    /// Count added and removed lines across structured hunks.
24    ///
25    /// # Parameters
26    ///
27    /// - `hunks`: hunks of one file's line diff.
28    ///
29    /// # Returns
30    ///
31    /// Insertions ([`StructuredLineKind::Add`]) and deletions
32    /// ([`StructuredLineKind::Del`]); context lines are not counted.
33    #[must_use]
34    pub fn from_hunks(hunks: &[StructuredHunk]) -> Self {
35        hunks
36            .iter()
37            .flat_map(|hunk| &hunk.lines)
38            .map(|line| line.kind)
39            .collect()
40    }
41
42    /// Scale this file's change bar against the largest change in its diffstat.
43    ///
44    /// Bars are drawn one column per changed line until `max_changes` exceeds
45    /// `width`; then every bar is scaled by `width / max_changes`, rounding up
46    /// so a changed file always gets at least one column. Within a bar the
47    /// columns are split between insertions and deletions in proportion,
48    /// keeping at least one column for each side that has changes.
49    ///
50    /// # Parameters
51    ///
52    /// - `max_changes`: the largest [`LineStat::changes`] among the rows drawn
53    ///   together (see [`DiffStat::max_changes`]).
54    /// - `width`: column budget for the widest bar.
55    ///
56    /// # Returns
57    ///
58    /// Column counts for the `+` and `-` parts; both are zero when this file
59    /// has no changed lines.
60    #[must_use]
61    pub fn bar(self, max_changes: usize, width: usize) -> StatBar {
62        let total = self.changes();
63        if total == 0 {
64            return StatBar::default();
65        }
66        let columns = if max_changes > width {
67            (total * width).div_ceil(max_changes).max(1)
68        } else {
69            total
70        };
71        // Round half up: floor(insertions * columns / total + 1/2).
72        let mut plus = (2 * self.insertions * columns + total) / (2 * total);
73        let mut minus = columns.saturating_sub(plus);
74        if self.insertions > 0 && plus == 0 {
75            plus = 1;
76            minus = minus.saturating_sub(1);
77        }
78        if self.deletions > 0 && minus == 0 {
79            minus = 1;
80            plus = plus.saturating_sub(1);
81        }
82        StatBar { plus, minus }
83    }
84}
85
86impl Add for LineStat {
87    type Output = Self;
88
89    fn add(self, rhs: Self) -> Self {
90        Self {
91            insertions: self.insertions + rhs.insertions,
92            deletions: self.deletions + rhs.deletions,
93        }
94    }
95}
96
97impl AddAssign for LineStat {
98    fn add_assign(&mut self, rhs: Self) {
99        *self = *self + rhs;
100    }
101}
102
103impl Sum for LineStat {
104    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
105        iter.fold(Self::default(), Add::add)
106    }
107}
108
109impl FromIterator<StructuredLineKind> for LineStat {
110    /// Count [`StructuredLineKind::Add`] as insertions and
111    /// [`StructuredLineKind::Del`] as deletions.
112    fn from_iter<I: IntoIterator<Item = StructuredLineKind>>(iter: I) -> Self {
113        let mut stat = Self::default();
114        for kind in iter {
115            match kind {
116                StructuredLineKind::Add => stat.insertions += 1,
117                StructuredLineKind::Del => stat.deletions += 1,
118                StructuredLineKind::Context => {}
119            }
120        }
121        stat
122    }
123}
124
125impl StructuredFileDiff {
126    /// Insertions and deletions across this file's hunks.
127    #[must_use]
128    pub fn line_stat(&self) -> LineStat {
129        LineStat::from_hunks(&self.hunks)
130    }
131}
132
133impl CombinedStructuredTextDiff {
134    /// Insertions and deletions of a combined (merge) diff body.
135    ///
136    /// A line counts as an insertion when any parent column is `+` and the
137    /// line has text, and as a deletion when any parent column is `-`.
138    #[must_use]
139    pub fn line_stat(&self) -> LineStat {
140        let mut stat = LineStat::default();
141        for line in self.hunks.iter().flat_map(|hunk| &hunk.lines) {
142            if line.parents.contains(&'+') && !line.text.is_empty() {
143                stat.insertions += 1;
144            }
145            if line.parents.contains(&'-') {
146                stat.deletions += 1;
147            }
148        }
149        stat
150    }
151}
152
153impl FileDiff {
154    /// Line counts for this file, whichever detail level produced it.
155    ///
156    /// # Returns
157    ///
158    /// [`Self::stat`] when stat detail was computed; otherwise the counts of
159    /// the structured (or combined) hunks; zero when neither is present (for
160    /// example binary files and name-only listings).
161    #[must_use]
162    pub fn line_stat(&self) -> LineStat {
163        if let Some(stat) = self.stat {
164            return stat;
165        }
166        if let Some(structured) = &self.structured {
167            return structured.line_stat();
168        }
169        self.combined
170            .as_ref()
171            .map(CombinedStructuredTextDiff::line_stat)
172            .unwrap_or_default()
173    }
174
175    /// Whether `whitespace` hid every change in this file.
176    ///
177    /// Git leaves such files out of `--stat` and `--numstat` under `-w`, `-b`,
178    /// `--ignore-space-at-eol` and `--ignore-blank-lines`. Only a text
179    /// modification with unchanged modes and no remaining changed lines
180    /// qualifies: additions, deletions, renames, mode changes and binary files
181    /// always stay listed.
182    ///
183    /// # Parameters
184    ///
185    /// - `whitespace`: the rules the file was diffed with.
186    #[must_use]
187    pub fn changes_all_ignored(&self, whitespace: WhitespaceMode) -> bool {
188        whitespace.is_active()
189            && self.status == DiffStatus::Modified
190            && !self.binary
191            && self.old_mode == self.new_mode
192            && self.line_stat().changes() == 0
193    }
194}
195
196/// Column counts for one diffstat row's change bar (see [`LineStat::bar`]).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub struct StatBar {
199    /// Columns drawn as `+` (insertions).
200    pub plus: usize,
201    /// Columns drawn as `-` (deletions).
202    pub minus: usize,
203}
204
205/// One file's row in a [`DiffStat`].
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct FileStat {
208    /// Display path (new side, or old for deletions).
209    pub path: String,
210    /// Pre-rename path when it differs from [`Self::path`].
211    pub old_path: Option<String>,
212    /// Change kind.
213    pub status: DiffStatus,
214    /// Either side looks like a binary blob (no line counts).
215    pub binary: bool,
216    /// Rename/copy similarity (0–100), when applicable.
217    pub similarity: Option<u32>,
218    /// Inserted and deleted lines.
219    pub lines: LineStat,
220}
221
222impl From<&FileDiff> for FileStat {
223    fn from(file: &FileDiff) -> Self {
224        Self {
225            path: file.path.clone(),
226            old_path: file.old_path.clone(),
227            status: file.status,
228            binary: file.binary,
229            similarity: file.similarity,
230            lines: file.line_stat(),
231        }
232    }
233}
234
235/// Per-file line counts plus totals for a whole diff.
236#[derive(Debug, Clone, PartialEq, Eq, Default)]
237pub struct DiffStat {
238    files: Vec<FileStat>,
239    totals: DiffTotals,
240}
241
242impl DiffStat {
243    /// Build a diffstat from file rows, summing their counts into totals.
244    ///
245    /// # Parameters
246    ///
247    /// - `files`: rows in display order.
248    #[must_use]
249    pub fn new(files: Vec<FileStat>) -> Self {
250        let lines: LineStat = files.iter().map(|file| file.lines).sum();
251        let totals = DiffTotals {
252            files_changed: files.len(),
253            insertions: lines.insertions,
254            deletions: lines.deletions,
255        };
256        Self { files, totals }
257    }
258
259    /// Build a diffstat from listed file diffs at any detail level.
260    ///
261    /// Each row's counts come from [`FileDiff::line_stat`].
262    ///
263    /// # Parameters
264    ///
265    /// - `files`: listed file diffs, in display order.
266    #[must_use]
267    pub fn from_files(files: &[FileDiff]) -> Self {
268        files.iter().map(FileStat::from).collect()
269    }
270
271    /// File rows in display order.
272    #[must_use]
273    pub fn files(&self) -> &[FileStat] {
274        &self.files
275    }
276
277    /// Changed-file count and line totals across all rows.
278    #[must_use]
279    pub fn totals(&self) -> DiffTotals {
280        self.totals
281    }
282
283    /// True when no file changed.
284    #[must_use]
285    pub fn is_empty(&self) -> bool {
286        self.files.is_empty()
287    }
288
289    /// The largest per-file change count: the scale for [`LineStat::bar`].
290    #[must_use]
291    pub fn max_changes(&self) -> usize {
292        self.files
293            .iter()
294            .map(|file| file.lines.changes())
295            .max()
296            .unwrap_or(0)
297    }
298
299    /// Take the file rows, dropping the totals.
300    #[must_use]
301    pub fn into_files(self) -> Vec<FileStat> {
302        self.files
303    }
304}
305
306impl FromIterator<FileStat> for DiffStat {
307    fn from_iter<I: IntoIterator<Item = FileStat>>(iter: I) -> Self {
308        Self::new(iter.into_iter().collect())
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::combined_diff_patch::{
316        CombinedStructuredHunk, CombinedStructuredLine, CombinedStructuredLineKind,
317    };
318    use crate::diff::structured::StructuredLine;
319
320    fn lines(insertions: usize, deletions: usize) -> LineStat {
321        LineStat {
322            insertions,
323            deletions,
324        }
325    }
326
327    fn line(kind: StructuredLineKind) -> StructuredLine {
328        StructuredLine {
329            kind,
330            old: None,
331            new: None,
332            segments: Vec::new(),
333            no_newline_at_eof: false,
334        }
335    }
336
337    fn hunk(kinds: &[StructuredLineKind]) -> StructuredHunk {
338        StructuredHunk {
339            old_start: 1,
340            new_start: 1,
341            old_lines: 0,
342            new_lines: 0,
343            context: None,
344            lines: kinds.iter().copied().map(line).collect(),
345        }
346    }
347
348    fn row(path: &str, binary: bool, stat: LineStat) -> FileStat {
349        FileStat {
350            path: path.to_owned(),
351            old_path: None,
352            status: DiffStatus::Modified,
353            binary,
354            similarity: None,
355            lines: stat,
356        }
357    }
358
359    fn listed(stat: Option<LineStat>, structured: Option<StructuredFileDiff>) -> FileDiff {
360        FileDiff {
361            path: "f".to_owned(),
362            old_path: None,
363            status: DiffStatus::Modified,
364            old_mode: None,
365            new_mode: None,
366            binary: false,
367            similarity: None,
368            stat,
369            structured,
370            combined: None,
371            combined_status: None,
372        }
373    }
374
375    // The scaling rule as first written with floating-point rounding; the
376    // integer form in `LineStat::bar` must agree with it everywhere.
377    fn float_bar(ins: usize, del: usize, max: usize, width: usize) -> (usize, usize) {
378        let total = ins + del;
379        if total == 0 {
380            return (0, 0);
381        }
382        let scaled = if max > width {
383            ((total * width).div_ceil(max)).max(1)
384        } else {
385            total
386        };
387        let mut plus = ((ins * scaled) as f64 / total as f64).round() as usize;
388        let mut minus = scaled.saturating_sub(plus);
389        if ins > 0 && plus == 0 {
390            plus = 1;
391            minus = minus.saturating_sub(1);
392        }
393        if del > 0 && minus == 0 {
394            minus = 1;
395            plus = plus.saturating_sub(1);
396        }
397        (plus, minus)
398    }
399
400    #[test]
401    fn line_stat_arithmetic() {
402        assert_eq!(lines(2, 3).changes(), 5);
403        assert_eq!(lines(1, 2) + lines(3, 4), lines(4, 6));
404        let mut sum = lines(1, 0);
405        sum += lines(0, 1);
406        assert_eq!(sum, lines(1, 1));
407        assert_eq!(
408            [lines(1, 2), lines(3, 0)].into_iter().sum::<LineStat>(),
409            lines(4, 2)
410        );
411        assert_eq!(
412            std::iter::empty::<LineStat>().sum::<LineStat>(),
413            LineStat::default()
414        );
415    }
416
417    #[test]
418    fn counts_structured_hunk_lines() {
419        use StructuredLineKind::{Add, Context, Del};
420        let hunks = [hunk(&[Context, Add, Add, Del, Context]), hunk(&[Del, Add])];
421        assert_eq!(LineStat::from_hunks(&hunks), lines(3, 2));
422        assert_eq!(LineStat::from_hunks(&[]), LineStat::default());
423        let structured = StructuredFileDiff {
424            old_mode: None,
425            new_mode: None,
426            old_encoding_lossy: false,
427            new_encoding_lossy: false,
428            hunks: hunks.to_vec(),
429        };
430        assert_eq!(structured.line_stat(), lines(3, 2));
431    }
432
433    #[test]
434    fn counts_combined_lines_by_parent_columns() {
435        let line = |parents: &[char], text: &str| CombinedStructuredLine {
436            parents: parents.to_vec(),
437            text: text.to_owned(),
438            kind: CombinedStructuredLineKind::from_parent_markers(parents),
439        };
440        let body = CombinedStructuredTextDiff {
441            hunks: vec![CombinedStructuredHunk {
442                header: "@@@ -1 -1 +1 @@@".to_owned(),
443                lines: vec![
444                    line(&['+', ' '], "added"),
445                    line(&['+', '+'], ""),
446                    line(&['-', ' '], "gone"),
447                    line(&[' ', ' '], "same"),
448                ],
449            }],
450        };
451        assert_eq!(body.line_stat(), lines(1, 1));
452    }
453
454    #[test]
455    fn listed_file_prefers_stat_then_hunks() {
456        let structured = StructuredFileDiff {
457            old_mode: None,
458            new_mode: None,
459            old_encoding_lossy: false,
460            new_encoding_lossy: false,
461            hunks: vec![hunk(&[StructuredLineKind::Add])],
462        };
463        assert_eq!(
464            listed(Some(lines(7, 8)), Some(structured.clone())).line_stat(),
465            lines(7, 8)
466        );
467        assert_eq!(listed(None, Some(structured)).line_stat(), lines(1, 0));
468        assert_eq!(listed(None, None).line_stat(), LineStat::default());
469    }
470
471    #[test]
472    fn changes_all_ignored_only_for_text_modifications_with_no_lines_left() {
473        let ws = WhitespaceMode::ignore_space_change();
474        let empty = listed(Some(LineStat::default()), None);
475        assert!(empty.changes_all_ignored(ws));
476        assert!(!empty.changes_all_ignored(WhitespaceMode::default()));
477        assert!(!listed(Some(lines(1, 1)), None).changes_all_ignored(ws));
478
479        let mut mode_change = empty.clone();
480        mode_change.old_mode = Some("100644".to_owned());
481        mode_change.new_mode = Some("100755".to_owned());
482        assert!(!mode_change.changes_all_ignored(ws));
483
484        let mut binary = empty.clone();
485        binary.binary = true;
486        assert!(!binary.changes_all_ignored(ws));
487
488        for status in [DiffStatus::Added, DiffStatus::Deleted, DiffStatus::Renamed] {
489            let mut other = empty.clone();
490            other.status = status;
491            assert!(!other.changes_all_ignored(ws), "{status:?}");
492        }
493    }
494
495    #[test]
496    fn diffstat_totals_and_scale() {
497        let stat: DiffStat = [
498            row("a", false, lines(3, 1)),
499            row("bin", true, LineStat::default()),
500            row("b", false, lines(0, 9)),
501        ]
502        .into_iter()
503        .collect();
504        assert_eq!(
505            stat.totals(),
506            DiffTotals {
507                files_changed: 3,
508                insertions: 3,
509                deletions: 10,
510            }
511        );
512        assert_eq!(stat.max_changes(), 9);
513        assert!(!stat.is_empty());
514        assert_eq!(stat.files()[1].path, "bin");
515        assert_eq!(stat.into_files().len(), 3);
516
517        let empty = DiffStat::new(Vec::new());
518        assert!(empty.is_empty());
519        assert_eq!(empty.max_changes(), 0);
520        assert_eq!(empty.totals(), DiffTotals::default());
521    }
522
523    #[test]
524    fn diffstat_from_listed_files() {
525        let mut renamed = listed(Some(lines(1, 1)), None);
526        renamed.status = DiffStatus::Renamed;
527        renamed.old_path = Some("old".to_owned());
528        renamed.similarity = Some(90);
529        let stat = DiffStat::from_files(&[renamed, listed(Some(lines(2, 0)), None)]);
530        assert_eq!(stat.totals().insertions, 3);
531        assert_eq!(stat.totals().deletions, 1);
532        let first = &stat.files()[0];
533        assert_eq!(first.status, DiffStatus::Renamed);
534        assert_eq!(first.old_path.as_deref(), Some("old"));
535        assert_eq!(first.similarity, Some(90));
536        assert!(!first.binary);
537    }
538
539    #[test]
540    fn bar_is_one_column_per_line_within_width() {
541        assert_eq!(lines(3, 2).bar(5, 40), StatBar { plus: 3, minus: 2 });
542        assert_eq!(lines(0, 0).bar(5, 40), StatBar::default());
543        assert_eq!(lines(1, 0).bar(40, 40), StatBar { plus: 1, minus: 0 });
544    }
545
546    #[test]
547    fn bar_scales_and_keeps_both_sides_visible() {
548        assert_eq!(lines(80, 0).bar(80, 40), StatBar { plus: 40, minus: 0 });
549        assert_eq!(
550            lines(40, 40).bar(80, 40),
551            StatBar {
552                plus: 20,
553                minus: 20
554            }
555        );
556        // A tiny change next to a huge one still gets one column.
557        assert_eq!(lines(1, 0).bar(10_000, 40), StatBar { plus: 1, minus: 0 });
558        // One deletion among many insertions keeps its `-` column.
559        assert_eq!(lines(999, 1).bar(1000, 40), StatBar { plus: 39, minus: 1 });
560        assert_eq!(lines(1, 999).bar(1000, 40), StatBar { plus: 1, minus: 39 });
561    }
562
563    #[test]
564    fn bar_matches_float_rounding() {
565        for width in [1, 2, 7, 40] {
566            for max in 0..=120 {
567                for ins in 0..=max {
568                    for del in 0..=(max - ins) {
569                        let bar = lines(ins, del).bar(max, width);
570                        assert_eq!(
571                            (bar.plus, bar.minus),
572                            float_bar(ins, del, max, width),
573                            "ins={ins} del={del} max={max} width={width}"
574                        );
575                    }
576                }
577            }
578        }
579    }
580}