git_cliff_core/
statistics.rs

1use std::collections::HashMap;
2
3use chrono::{TimeZone, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::release::Release;
7
8/// Aggregated information about how many times a specific link appeared in
9/// commit messages.
10#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct LinkCount {
13    /// Text of the link.
14    pub text: String,
15    /// URL of the link.
16    pub href: String,
17    /// The number of times this link was referenced.
18    pub count: usize,
19}
20
21/// Aggregated statistics about commits in the release.
22#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Statistics {
24    /// The total number of commits included in the release.
25    pub commit_count: usize,
26    /// The time span, in days, from the first to the last commit in the
27    /// release. Only present if there is more than one commit.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub commits_timespan: Option<i64>,
30    /// The number of commits that follow the Conventional Commits
31    /// specification.
32    pub conventional_commit_count: usize,
33    /// The number of times each link was referenced in commit messages.
34    pub links: Vec<LinkCount>,
35    /// The number of days since the previous release.
36    /// Only present if this is not the first release.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub days_passed_since_last_release: Option<i64>,
39}
40
41impl From<&Release<'_>> for Statistics {
42    /// Aggregates various statistics from the release data.
43    ///
44    /// This method computes several metrics based on the current release and
45    /// its commits:
46    ///
47    /// - Counts the total number of commits.
48    /// - Determines the number of days between the first and last commit.
49    /// - Counts the number of commits that follow the Conventional Commits specification.
50    /// - Tallies how many times each link appears across all commit messages.
51    /// - Calculates the number of days since the previous release, if available.
52    fn from(release: &Release) -> Self {
53        let commit_count = release.commits.len();
54        let commits_timespan = if release.commits.len() < 2 {
55            trace!(
56                "commits_timespan: insufficient commits to calculate duration (found {})",
57                release.commits.len()
58            );
59            None
60        } else {
61            release
62                .commits
63                .iter()
64                .min_by_key(|c| c.committer.timestamp)
65                .zip(release.commits.iter().max_by_key(|c| c.committer.timestamp))
66                .and_then(|(first, last)| {
67                    Utc.timestamp_opt(first.committer.timestamp, 0)
68                        .single()
69                        .zip(Utc.timestamp_opt(last.committer.timestamp, 0).single())
70                        .map(|(start, end)| (end.date_naive() - start.date_naive()).num_days())
71                })
72        };
73        let conventional_commit_count = release.commits.iter().filter(|c| c.conv.is_some()).count();
74        let mut links: Vec<LinkCount> = release
75            .commits
76            .iter()
77            .fold(HashMap::new(), |mut acc, c| {
78                for link in &c.links {
79                    *acc.entry((link.text.clone(), link.href.clone()))
80                        .or_insert(0) += 1;
81                }
82                acc
83            })
84            .into_iter()
85            .map(|((text, href), count)| LinkCount { text, href, count })
86            .collect();
87        links.sort_by(|lhs, rhs| {
88            rhs.count
89                .cmp(&lhs.count)
90                .then_with(|| lhs.text.cmp(&rhs.text))
91                .then_with(|| lhs.href.cmp(&rhs.href))
92        });
93        let days_passed_since_last_release = match release.previous.as_ref() {
94            Some(prev) => release
95                .timestamp
96                .map(|ts| Utc.timestamp_opt(ts, 0))
97                .unwrap_or_else(|| {
98                    let now = Utc::now();
99                    Utc.timestamp_opt(now.timestamp(), 0)
100                })
101                .single()
102                .zip(
103                    prev.timestamp
104                        .and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
105                )
106                .map(|(curr, prev)| (curr.date_naive() - prev.date_naive()).num_days()),
107            None => {
108                trace!("days_passed_since_last_release: previous release not found");
109                None
110            }
111        };
112        Self {
113            commit_count,
114            commits_timespan,
115            conventional_commit_count,
116            links,
117            days_passed_since_last_release,
118        }
119    }
120}
121
122#[cfg(test)]
123mod test {
124    use lazy_regex::Regex;
125    use pretty_assertions::assert_eq;
126
127    use super::*;
128    use crate::commit::{Commit, Signature};
129    use crate::config::LinkParser;
130    use crate::error::Result;
131    use crate::release::Release;
132    #[test]
133    fn from_release() -> Result<()> {
134        fn find_count(v: &[LinkCount], text: &str, href: &str) -> Option<usize> {
135            v.iter()
136                .find(|l| l.text == text && l.href == href)
137                .map(|l| l.count)
138        }
139        let link_parsers = vec![
140            LinkParser {
141                pattern: Regex::new("RFC(\\d+)")?,
142                href: String::from("rfc://$1"),
143                text: None,
144            },
145            LinkParser {
146                pattern: Regex::new("#(\\d+)")?,
147                href: String::from("https://github.com/$1"),
148                text: None,
149            },
150        ];
151        let unconventional_commits = vec![
152            Commit {
153                id: String::from("123123"),
154                message: String::from("add feature"),
155                committer: Signature {
156                    name: Some(String::from("John Doe")),
157                    email: Some(String::from("john@doe.com")),
158                    timestamp: 1649201111,
159                },
160                ..Default::default()
161            },
162            Commit {
163                id: String::from("123123"),
164                message: String::from("fix feature"),
165                committer: Signature {
166                    name: Some(String::from("John Doe")),
167                    email: Some(String::from("john@doe.com")),
168                    timestamp: 1649201112,
169                },
170                ..Default::default()
171            },
172            Commit {
173                id: String::from("123123"),
174                message: String::from("refactor feature"),
175                committer: Signature {
176                    name: Some(String::from("John Doe")),
177                    email: Some(String::from("john@doe.com")),
178                    timestamp: 1649201113,
179                },
180                ..Default::default()
181            },
182            Commit {
183                id: String::from("123123"),
184                message: String::from("add docs for RFC456-related feature"),
185                committer: Signature {
186                    name: Some(String::from("John Doe")),
187                    email: Some(String::from("john@doe.com")),
188                    timestamp: 1649201114,
189                },
190                ..Default::default()
191            },
192        ];
193        let conventional_commits = vec![
194            Commit {
195                id: String::from("123123"),
196                message: String::from("perf: improve feature performance, fixes #455"),
197                committer: Signature {
198                    name: Some(String::from("John Doe")),
199                    email: Some(String::from("john@doe.com")),
200                    timestamp: 1649287515,
201                },
202                ..Default::default()
203            },
204            Commit {
205                id: String::from("123123"),
206                message: String::from("style(schema): fix feature schema"),
207                committer: Signature {
208                    name: Some(String::from("John Doe")),
209                    email: Some(String::from("john@doe.com")),
210                    timestamp: 1649287516,
211                },
212                ..Default::default()
213            },
214            Commit {
215                id: String::from("123123"),
216                message: String::from("test: add unit tests for RFC456-related feature"),
217                committer: Signature {
218                    name: Some(String::from("John Doe")),
219                    email: Some(String::from("john@doe.com")),
220                    timestamp: 1649287517,
221                },
222                ..Default::default()
223            },
224        ];
225        let commits: Vec<Commit> = [unconventional_commits.clone(), conventional_commits.clone()]
226            .concat()
227            .into_iter()
228            .map(|c| c.parse_links(&link_parsers))
229            .map(|c| c.clone().into_conventional().unwrap_or(c))
230            .collect();
231        let release = Release {
232            commits,
233            timestamp: Some(1649373910),
234            previous: Some(Box::new(Release {
235                timestamp: Some(1649201110),
236                ..Default::default()
237            })),
238            repository: Some(String::from("/root/repo")),
239            ..Default::default()
240        };
241
242        let statistics = Statistics::from(&release);
243        assert_eq!(release.commits.len(), statistics.commit_count);
244        assert_eq!(Some(1), statistics.commits_timespan);
245        assert_eq!(
246            conventional_commits.len(),
247            statistics.conventional_commit_count
248        );
249        assert_eq!(
250            Some(2),
251            find_count(&statistics.links, "RFC456", "rfc://456")
252        );
253        assert_eq!(
254            Some(1),
255            find_count(&statistics.links, "#455", "https://github.com/455")
256        );
257        assert_eq!(Some(2), statistics.days_passed_since_last_release);
258
259        let commits = vec![Commit {
260            id: String::from("123123"),
261            message: String::from("add feature"),
262            committer: Signature {
263                name: Some(String::from("John Doe")),
264                email: Some(String::from("john@doe.com")),
265                timestamp: 1649201111,
266            },
267            ..Default::default()
268        }];
269        let release = Release {
270            commits,
271            timestamp: Some(1649373910),
272            previous: Some(Box::new(Release {
273                timestamp: Some(1649201110),
274                ..Default::default()
275            })),
276            repository: Some(String::from("/root/repo")),
277            ..Default::default()
278        };
279
280        let statistics = Statistics::from(&release);
281        assert_eq!(None, statistics.commits_timespan);
282
283        let commits = vec![];
284        let release = Release {
285            commits,
286            timestamp: Some(1649373910),
287            previous: None,
288            repository: Some(String::from("/root/repo")),
289            ..Default::default()
290        };
291
292        let statistics = Statistics::from(&release);
293        assert_eq!(None, statistics.days_passed_since_last_release);
294
295        Ok(())
296    }
297}