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    /// Old-side byte length when [`Self::binary`] is true.
217    pub old_size: u64,
218    /// New-side byte length when [`Self::binary`] is true.
219    pub new_size: u64,
220    /// Rename/copy similarity (0–100), when applicable.
221    pub similarity: Option<u32>,
222    /// Inserted and deleted lines.
223    pub lines: LineStat,
224}
225
226impl From<&FileDiff> for FileStat {
227    fn from(file: &FileDiff) -> Self {
228        Self {
229            path: file.path.clone(),
230            old_path: file.old_path.clone(),
231            status: file.status,
232            binary: file.binary,
233            old_size: file.old_size,
234            new_size: file.new_size,
235            similarity: file.similarity,
236            lines: file.line_stat(),
237        }
238    }
239}
240
241/// Per-file line counts plus totals for a whole diff.
242#[derive(Debug, Clone, PartialEq, Eq, Default)]
243pub struct DiffStat {
244    files: Vec<FileStat>,
245    totals: DiffTotals,
246}
247
248impl DiffStat {
249    /// Build a diffstat from file rows, summing their counts into totals.
250    ///
251    /// # Parameters
252    ///
253    /// - `files`: rows in display order.
254    #[must_use]
255    pub fn new(files: Vec<FileStat>) -> Self {
256        let lines: LineStat = files.iter().map(|file| file.lines).sum();
257        let totals = DiffTotals {
258            files_changed: files.len(),
259            insertions: lines.insertions,
260            deletions: lines.deletions,
261        };
262        Self { files, totals }
263    }
264
265    /// Build a diffstat from listed file diffs at any detail level.
266    ///
267    /// Each row's counts come from [`FileDiff::line_stat`].
268    ///
269    /// # Parameters
270    ///
271    /// - `files`: listed file diffs, in display order.
272    #[must_use]
273    pub fn from_files(files: &[FileDiff]) -> Self {
274        files.iter().map(FileStat::from).collect()
275    }
276
277    /// File rows in display order.
278    #[must_use]
279    pub fn files(&self) -> &[FileStat] {
280        &self.files
281    }
282
283    /// Changed-file count and line totals across all rows.
284    #[must_use]
285    pub fn totals(&self) -> DiffTotals {
286        self.totals
287    }
288
289    /// True when no file changed.
290    #[must_use]
291    pub fn is_empty(&self) -> bool {
292        self.files.is_empty()
293    }
294
295    /// The largest per-file change count: the scale for [`LineStat::bar`].
296    #[must_use]
297    pub fn max_changes(&self) -> usize {
298        self.files
299            .iter()
300            .map(|file| file.lines.changes())
301            .max()
302            .unwrap_or(0)
303    }
304
305    /// Take the file rows, dropping the totals.
306    #[must_use]
307    pub fn into_files(self) -> Vec<FileStat> {
308        self.files
309    }
310}
311
312impl FromIterator<FileStat> for DiffStat {
313    fn from_iter<I: IntoIterator<Item = FileStat>>(iter: I) -> Self {
314        Self::new(iter.into_iter().collect())
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::combined_diff_patch::{
322        CombinedStructuredHunk, CombinedStructuredLine, CombinedStructuredLineKind,
323    };
324    use crate::diff::structured::StructuredLine;
325
326    fn lines(insertions: usize, deletions: usize) -> LineStat {
327        LineStat {
328            insertions,
329            deletions,
330        }
331    }
332
333    fn line(kind: StructuredLineKind) -> StructuredLine {
334        StructuredLine {
335            kind,
336            old: None,
337            new: None,
338            segments: Vec::new(),
339            no_newline_at_eof: false,
340        }
341    }
342
343    fn hunk(kinds: &[StructuredLineKind]) -> StructuredHunk {
344        StructuredHunk {
345            old_start: 1,
346            new_start: 1,
347            old_lines: 0,
348            new_lines: 0,
349            context: None,
350            lines: kinds.iter().copied().map(line).collect(),
351        }
352    }
353
354    fn row(path: &str, binary: bool, stat: LineStat) -> FileStat {
355        FileStat {
356            path: path.to_owned(),
357            old_path: None,
358            status: DiffStatus::Modified,
359            binary,
360            old_size: 0,
361            new_size: 0,
362            similarity: None,
363            lines: stat,
364        }
365    }
366
367    fn listed(stat: Option<LineStat>, structured: Option<StructuredFileDiff>) -> FileDiff {
368        FileDiff {
369            path: "f".to_owned(),
370            old_path: None,
371            status: DiffStatus::Modified,
372            old_mode: None,
373            new_mode: None,
374            binary: false,
375            old_size: 0,
376            new_size: 0,
377            similarity: None,
378            stat,
379            structured,
380            combined: None,
381            combined_status: None,
382        }
383    }
384
385    // The scaling rule as first written with floating-point rounding; the
386    // integer form in `LineStat::bar` must agree with it everywhere.
387    fn float_bar(ins: usize, del: usize, max: usize, width: usize) -> (usize, usize) {
388        let total = ins + del;
389        if total == 0 {
390            return (0, 0);
391        }
392        let scaled = if max > width {
393            ((total * width).div_ceil(max)).max(1)
394        } else {
395            total
396        };
397        let mut plus = ((ins * scaled) as f64 / total as f64).round() as usize;
398        let mut minus = scaled.saturating_sub(plus);
399        if ins > 0 && plus == 0 {
400            plus = 1;
401            minus = minus.saturating_sub(1);
402        }
403        if del > 0 && minus == 0 {
404            minus = 1;
405            plus = plus.saturating_sub(1);
406        }
407        (plus, minus)
408    }
409
410    #[test]
411    fn line_stat_arithmetic() {
412        assert_eq!(lines(2, 3).changes(), 5);
413        assert_eq!(lines(1, 2) + lines(3, 4), lines(4, 6));
414        let mut sum = lines(1, 0);
415        sum += lines(0, 1);
416        assert_eq!(sum, lines(1, 1));
417        assert_eq!(
418            [lines(1, 2), lines(3, 0)].into_iter().sum::<LineStat>(),
419            lines(4, 2)
420        );
421        assert_eq!(
422            std::iter::empty::<LineStat>().sum::<LineStat>(),
423            LineStat::default()
424        );
425    }
426
427    #[test]
428    fn counts_structured_hunk_lines() {
429        use StructuredLineKind::{Add, Context, Del};
430        let hunks = [hunk(&[Context, Add, Add, Del, Context]), hunk(&[Del, Add])];
431        assert_eq!(LineStat::from_hunks(&hunks), lines(3, 2));
432        assert_eq!(LineStat::from_hunks(&[]), LineStat::default());
433        let structured = StructuredFileDiff {
434            old_mode: None,
435            new_mode: None,
436            old_encoding_lossy: false,
437            new_encoding_lossy: false,
438            hunks: hunks.to_vec(),
439        };
440        assert_eq!(structured.line_stat(), lines(3, 2));
441    }
442
443    #[test]
444    fn counts_combined_lines_by_parent_columns() {
445        let line = |parents: &[char], text: &str| CombinedStructuredLine {
446            parents: parents.to_vec(),
447            text: text.to_owned(),
448            kind: CombinedStructuredLineKind::from_parent_markers(parents),
449        };
450        let body = CombinedStructuredTextDiff {
451            hunks: vec![CombinedStructuredHunk {
452                header: "@@@ -1 -1 +1 @@@".to_owned(),
453                lines: vec![
454                    line(&['+', ' '], "added"),
455                    line(&['+', '+'], ""),
456                    line(&['-', ' '], "gone"),
457                    line(&[' ', ' '], "same"),
458                ],
459            }],
460        };
461        assert_eq!(body.line_stat(), lines(1, 1));
462    }
463
464    #[test]
465    fn listed_file_prefers_stat_then_hunks() {
466        let structured = StructuredFileDiff {
467            old_mode: None,
468            new_mode: None,
469            old_encoding_lossy: false,
470            new_encoding_lossy: false,
471            hunks: vec![hunk(&[StructuredLineKind::Add])],
472        };
473        assert_eq!(
474            listed(Some(lines(7, 8)), Some(structured.clone())).line_stat(),
475            lines(7, 8)
476        );
477        assert_eq!(listed(None, Some(structured)).line_stat(), lines(1, 0));
478        assert_eq!(listed(None, None).line_stat(), LineStat::default());
479    }
480
481    #[test]
482    fn changes_all_ignored_only_for_text_modifications_with_no_lines_left() {
483        let ws = WhitespaceMode::ignore_space_change();
484        let empty = listed(Some(LineStat::default()), None);
485        assert!(empty.changes_all_ignored(ws));
486        assert!(!empty.changes_all_ignored(WhitespaceMode::default()));
487        assert!(!listed(Some(lines(1, 1)), None).changes_all_ignored(ws));
488
489        let mut mode_change = empty.clone();
490        mode_change.old_mode = Some("100644".to_owned());
491        mode_change.new_mode = Some("100755".to_owned());
492        assert!(!mode_change.changes_all_ignored(ws));
493
494        let mut binary = empty.clone();
495        binary.binary = true;
496        assert!(!binary.changes_all_ignored(ws));
497
498        for status in [DiffStatus::Added, DiffStatus::Deleted, DiffStatus::Renamed] {
499            let mut other = empty.clone();
500            other.status = status;
501            assert!(!other.changes_all_ignored(ws), "{status:?}");
502        }
503    }
504
505    #[test]
506    fn diffstat_totals_and_scale() {
507        let stat: DiffStat = [
508            row("a", false, lines(3, 1)),
509            row("bin", true, LineStat::default()),
510            row("b", false, lines(0, 9)),
511        ]
512        .into_iter()
513        .collect();
514        assert_eq!(
515            stat.totals(),
516            DiffTotals {
517                files_changed: 3,
518                insertions: 3,
519                deletions: 10,
520            }
521        );
522        assert_eq!(stat.max_changes(), 9);
523        assert!(!stat.is_empty());
524        assert_eq!(stat.files()[1].path, "bin");
525        assert_eq!(stat.into_files().len(), 3);
526
527        let empty = DiffStat::new(Vec::new());
528        assert!(empty.is_empty());
529        assert_eq!(empty.max_changes(), 0);
530        assert_eq!(empty.totals(), DiffTotals::default());
531    }
532
533    #[test]
534    fn diffstat_from_listed_files() {
535        let mut renamed = listed(Some(lines(1, 1)), None);
536        renamed.status = DiffStatus::Renamed;
537        renamed.old_path = Some("old".to_owned());
538        renamed.similarity = Some(90);
539        let stat = DiffStat::from_files(&[renamed, listed(Some(lines(2, 0)), None)]);
540        assert_eq!(stat.totals().insertions, 3);
541        assert_eq!(stat.totals().deletions, 1);
542        let first = &stat.files()[0];
543        assert_eq!(first.status, DiffStatus::Renamed);
544        assert_eq!(first.old_path.as_deref(), Some("old"));
545        assert_eq!(first.similarity, Some(90));
546        assert!(!first.binary);
547    }
548
549    #[test]
550    fn bar_is_one_column_per_line_within_width() {
551        assert_eq!(lines(3, 2).bar(5, 40), StatBar { plus: 3, minus: 2 });
552        assert_eq!(lines(0, 0).bar(5, 40), StatBar::default());
553        assert_eq!(lines(1, 0).bar(40, 40), StatBar { plus: 1, minus: 0 });
554    }
555
556    #[test]
557    fn bar_scales_and_keeps_both_sides_visible() {
558        assert_eq!(lines(80, 0).bar(80, 40), StatBar { plus: 40, minus: 0 });
559        assert_eq!(
560            lines(40, 40).bar(80, 40),
561            StatBar {
562                plus: 20,
563                minus: 20
564            }
565        );
566        // A tiny change next to a huge one still gets one column.
567        assert_eq!(lines(1, 0).bar(10_000, 40), StatBar { plus: 1, minus: 0 });
568        // One deletion among many insertions keeps its `-` column.
569        assert_eq!(lines(999, 1).bar(1000, 40), StatBar { plus: 39, minus: 1 });
570        assert_eq!(lines(1, 999).bar(1000, 40), StatBar { plus: 1, minus: 39 });
571    }
572
573    #[test]
574    fn bar_matches_float_rounding() {
575        for width in [1, 2, 7, 40] {
576            for max in 0..=120 {
577                for ins in 0..=max {
578                    for del in 0..=(max - ins) {
579                        let bar = lines(ins, del).bar(max, width);
580                        assert_eq!(
581                            (bar.plus, bar.minus),
582                            float_bar(ins, del, max, width),
583                            "ins={ins} del={del} max={max} width={width}"
584                        );
585                    }
586                }
587            }
588        }
589    }
590}