Skip to main content

review_queue/source/
diffstat.rs

1//! Renders a `git diff --stat`/`jj diff --stat`-style summary from per-file line counts, for
2//! sources that report changed files via their own API (Phabricator's `differential.querydiffs`)
3//! or only an aggregate count (GitHub's PR object) rather than a local git/jj diff.
4
5/// One changed file's line counts, as reported by a source's API.
6pub struct FileChange {
7    pub path: String,
8    pub additions: u64,
9    pub deletions: u64,
10}
11
12/// Widest a single file's +/- bar is allowed to get, regardless of how large its own change
13/// count is - matches `git diff --stat`'s own scaling behavior, just with a fixed cap instead of
14/// scaling to the terminal width (there's no terminal column to size against here).
15const MAX_BAR_WIDTH: usize = 24;
16
17/// Cap on how many per-file lines are rendered - large PRs/revisions (generated code, vendored
18/// dependency bumps) can touch hundreds of files, which would otherwise blow out the TUI's detail
19/// panel; the rest are folded into a single "N more files not shown" line instead.
20const MAX_FILES_SHOWN: usize = 10;
21
22/// Render `changes` the way `git diff --stat` would: one `path | N +++---` line per file (up to
23/// [`MAX_FILES_SHOWN`], widest bar capped at [`MAX_BAR_WIDTH`], `+`/`-` split proportionally to
24/// that file's own additions/deletions), then a "N more files not shown" line if any were
25/// omitted, followed by a `N files changed, X insertions(+), Y deletions(-)` summary line (always
26/// counting every file, shown or not). Returns an empty string for no changes.
27pub fn format_diffstat(changes: &[FileChange]) -> String {
28    if changes.is_empty() {
29        return String::new();
30    }
31
32    let shown = &changes[..changes.len().min(MAX_FILES_SHOWN)];
33    let name_w = shown
34        .iter()
35        .map(|c| c.path.chars().count())
36        .max()
37        .unwrap_or(0);
38    let max_total = shown
39        .iter()
40        .map(|c| c.additions + c.deletions)
41        .max()
42        .unwrap_or(0)
43        .max(1);
44
45    let mut total_add = 0u64;
46    let mut total_del = 0u64;
47    let mut lines = Vec::with_capacity(shown.len() + 2);
48    for c in changes {
49        total_add += c.additions;
50        total_del += c.deletions;
51    }
52    for c in shown {
53        let total = c.additions + c.deletions;
54
55        let bar_len = ((total as f64 / max_total as f64) * MAX_BAR_WIDTH as f64).round() as usize;
56        let bar_len = if total > 0 { bar_len.max(1) } else { 0 };
57        let plus_len = (bar_len as u64 * c.additions)
58            .checked_div(total)
59            .unwrap_or(0) as usize;
60        let minus_len = bar_len.saturating_sub(plus_len);
61
62        lines.push(format!(
63            "{:<name_w$} | {total:>4} {}{}",
64            c.path,
65            "+".repeat(plus_len),
66            "-".repeat(minus_len),
67        ));
68    }
69    let hidden = changes.len() - shown.len();
70    if hidden > 0 {
71        lines.push(format!(
72            "{hidden} more file{} not shown",
73            if hidden == 1 { "" } else { "s" }
74        ));
75    }
76    lines.push(summary_line(changes.len() as u64, total_add, total_del));
77
78    lines.join("\n")
79}
80
81/// Just the `N files changed, X insertions(+), Y deletions(-)` line, for sources that only expose
82/// aggregate counts (GitHub's PR object) rather than a per-file breakdown.
83pub fn format_summary(file_count: u64, additions: u64, deletions: u64) -> String {
84    summary_line(file_count, additions, deletions)
85}
86
87fn summary_line(file_count: u64, total_add: u64, total_del: u64) -> String {
88    let mut summary = format!(
89        "{file_count} file{} changed",
90        if file_count == 1 { "" } else { "s" }
91    );
92    if total_add > 0 {
93        summary.push_str(&format!(
94            ", {total_add} insertion{}(+)",
95            if total_add == 1 { "" } else { "s" }
96        ));
97    }
98    if total_del > 0 {
99        summary.push_str(&format!(
100            ", {total_del} deletion{}(-)",
101            if total_del == 1 { "" } else { "s" }
102        ));
103    }
104    summary
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn empty_changes_render_nothing() {
113        assert_eq!(format_diffstat(&[]), "");
114    }
115
116    #[test]
117    fn single_file_matches_git_style_summary() {
118        let changes = [FileChange {
119            path: "src/main.rs".into(),
120            additions: 3,
121            deletions: 1,
122        }];
123        let out = format_diffstat(&changes);
124        let lines: Vec<&str> = out.lines().collect();
125        assert_eq!(lines.len(), 2);
126        assert!(lines[0].starts_with("src/main.rs |    4 "));
127        assert_eq!(lines[1], "1 file changed, 3 insertions(+), 1 deletion(-)");
128    }
129
130    #[test]
131    fn omits_the_clause_for_a_side_with_no_changes() {
132        let added_only = [FileChange {
133            path: "a.rs".into(),
134            additions: 2,
135            deletions: 0,
136        }];
137        assert_eq!(
138            format_diffstat(&added_only).lines().last().unwrap(),
139            "1 file changed, 2 insertions(+)"
140        );
141
142        let removed_only = [FileChange {
143            path: "a.rs".into(),
144            additions: 0,
145            deletions: 5,
146        }];
147        assert_eq!(
148            format_diffstat(&removed_only).lines().last().unwrap(),
149            "1 file changed, 5 deletions(-)"
150        );
151    }
152
153    #[test]
154    fn bars_scale_relative_to_the_largest_change_and_never_exceed_the_cap() {
155        let changes = [
156            FileChange {
157                path: "big.rs".into(),
158                additions: 100,
159                deletions: 0,
160            },
161            FileChange {
162                path: "small.rs".into(),
163                additions: 1,
164                deletions: 0,
165            },
166        ];
167        let out = format_diffstat(&changes);
168        let lines: Vec<&str> = out.lines().collect();
169        let big_bar = lines[0].split_whitespace().last().unwrap();
170        let small_bar = lines[1].split_whitespace().last().unwrap();
171        assert_eq!(big_bar.len(), MAX_BAR_WIDTH);
172        assert!(small_bar.len() < big_bar.len());
173    }
174
175    #[test]
176    fn caps_file_lines_and_reports_the_rest_as_hidden() {
177        let changes: Vec<FileChange> = (0..12)
178            .map(|i| FileChange {
179                path: format!("f{i}.rs"),
180                additions: 1,
181                deletions: 0,
182            })
183            .collect();
184        let out = format_diffstat(&changes);
185        let lines: Vec<&str> = out.lines().collect();
186
187        // 10 file lines + "2 more files not shown" + the summary line.
188        assert_eq!(lines.len(), 12);
189        assert_eq!(lines[10], "2 more files not shown");
190        assert_eq!(lines[11], "12 files changed, 12 insertions(+)");
191    }
192
193    #[test]
194    fn does_not_add_a_hidden_line_when_under_the_cap() {
195        let changes = [FileChange {
196            path: "a.rs".into(),
197            additions: 1,
198            deletions: 0,
199        }];
200        let out = format_diffstat(&changes);
201        assert!(!out.contains("not shown"));
202    }
203
204    #[test]
205    fn summary_pluralizes_a_single_file_and_change() {
206        let changes = [FileChange {
207            path: "a.rs".into(),
208            additions: 1,
209            deletions: 0,
210        }];
211        assert_eq!(
212            format_diffstat(&changes).lines().last().unwrap(),
213            "1 file changed, 1 insertion(+)"
214        );
215    }
216
217    #[test]
218    fn format_summary_matches_format_diffstats_own_summary_line() {
219        assert_eq!(
220            format_summary(2, 3, 1),
221            "2 files changed, 3 insertions(+), 1 deletion(-)"
222        );
223        assert_eq!(format_summary(1, 1, 0), "1 file changed, 1 insertion(+)");
224        assert_eq!(format_summary(0, 0, 0), "0 files changed");
225    }
226}