Skip to main content

git_stats/
model.rs

1//! Value objects: plain data passed between the pure logic and the gix
2//! infrastructure. Nothing here performs I/O or depends on gix.
3
4use clap::ValueEnum;
5use tabled::Tabled;
6
7/// A commit author, resolved through `.mailmap` to match git's `%aN`/`%aE`.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct Author {
10    pub name: String,
11    pub email: String,
12}
13
14/// A single git trailer such as `Reviewed-by: Ada <ada@example.com>`.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Trailer {
17    pub token: String,
18    pub value: String,
19}
20
21/// Everything the logic needs to know about a commit, independent of git.
22///
23/// `time_seconds` is the committer timestamp (seconds since the Unix epoch),
24/// because that is the date `git log --since/--until` filters on.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CommitMeta {
27    pub author: Author,
28    pub time_seconds: i64,
29    pub trailers: Vec<Trailer>,
30}
31
32/// Line and file changes for a single commit, equivalent to one commit's
33/// `git log --numstat` output. Merge commits carry the default (all zero),
34/// matching git's default of emitting no numstat for merges.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct DiffStat {
37    pub insertions: u64,
38    pub deletions: u64,
39    pub files: u64,
40}
41
42/// Which column the stats table is sorted by.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
44pub enum SortBy {
45    /// Sort by author alphabetic order
46    Author,
47    /// Sort by number of commits
48    Commits,
49    /// Sort by number of files touched
50    Files,
51    /// Sort by number of insertions
52    Insertions,
53    /// Sort by number of deletions
54    Deletions,
55    /// Sort by net lines of change
56    Net,
57}
58
59/// Parsed, validated options handed from the CLI down to the application layer.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Options {
62    /// Revision range, interpreted by the infrastructure (e.g. `origin..HEAD`).
63    pub range: String,
64    /// Group and display authors as `Name <email>` rather than just `Name`.
65    pub email: bool,
66    /// Also produce the reviewer/tester table from git trailers.
67    pub reviews: bool,
68    pub sort: SortBy,
69    pub reverse: bool,
70    /// Author regex patterns; a commit matches if any pattern matches. Empty
71    /// means no author filtering.
72    pub authors: Vec<String>,
73    pub since: Option<String>,
74    pub until: Option<String>,
75}
76
77/// Aggregated per-author statistics. Doubles as one row of the stats table.
78#[derive(Debug, Clone, PartialEq, Eq, Tabled)]
79pub struct Stat {
80    #[tabled(rename = "Author")]
81    pub author: String,
82    #[tabled(rename = "Commits")]
83    pub commits: u64,
84    #[tabled(rename = "Changed Files")]
85    pub num_files: u64,
86    #[tabled(rename = "Insertions", display = "display_add")]
87    pub insertions: u64,
88    #[tabled(rename = "Deletions", display = "display_del")]
89    pub deletions: u64,
90    #[tabled(rename = "Net Δ", display = "display_net")]
91    pub net: i64,
92}
93
94/// Aggregated per-reviewer statistics. Doubles as one row of the reviews table.
95#[derive(Debug, Clone, PartialEq, Eq, Tabled)]
96pub struct Review {
97    #[tabled(rename = "Reviewer/Tester")]
98    pub author: String,
99    #[tabled(rename = "Commits")]
100    pub commits: u64,
101}
102
103// These take `&u64`/`&i64` rather than the values by copy because tabled's
104// `display` attribute requires a by-reference signature.
105
106/// Format a deletion count: `0` stays `0`, otherwise it is shown as `-n`.
107#[must_use]
108pub fn display_del(n: &u64) -> String {
109    match n {
110        0 => "0".to_string(),
111        n => format!("-{n}"),
112    }
113}
114
115/// Format an insertion count: `0` stays `0`, otherwise it is shown as `+n`.
116#[must_use]
117pub fn display_add(n: &u64) -> String {
118    match n {
119        0 => "0".to_string(),
120        n => format!("+{n}"),
121    }
122}
123
124/// Format a net line delta: positive values get a leading `+`, zero and
125/// negative values render with their natural sign (`0`, `-5`).
126#[must_use]
127pub fn display_net(n: &i64) -> String {
128    if *n > 0 {
129        format!("+{n}")
130    } else {
131        format!("{n}")
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use hegel::generators;
139
140    #[hegel::test]
141    fn display_add_signs_nonzero(tc: hegel::TestCase) {
142        let n = tc.draw(generators::integers::<u64>());
143        let rendered = display_add(&n);
144        if n == 0 {
145            assert_eq!(rendered, "0");
146        } else {
147            assert_eq!(rendered, format!("+{n}"));
148        }
149    }
150
151    #[hegel::test]
152    fn display_del_signs_nonzero(tc: hegel::TestCase) {
153        let n = tc.draw(generators::integers::<u64>());
154        let rendered = display_del(&n);
155        if n == 0 {
156            assert_eq!(rendered, "0");
157        } else {
158            assert_eq!(rendered, format!("-{n}"));
159        }
160    }
161
162    /// Across the full `i64` range (including `MIN`, `MAX`, `0`), `display_net`
163    /// never panics and follows its sign rules. This is precisely the case the
164    /// old `_ => todo!()` arm claimed to need but could never reach.
165    #[hegel::test]
166    fn display_net_signs_and_never_panics(tc: hegel::TestCase) {
167        let n = tc.draw(generators::integers::<i64>());
168        let rendered = display_net(&n);
169        if n > 0 {
170            assert_eq!(rendered, format!("+{n}"));
171        } else {
172            assert_eq!(rendered, n.to_string());
173        }
174    }
175}