Skip to main content

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            tracing::trace!(
56                "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 = if let Some(prev) = release.previous.as_ref() {
94            release
95                .timestamp
96                .map_or_else(
97                    || {
98                        let now = Utc::now();
99                        Utc.timestamp_opt(now.timestamp(), 0)
100                    },
101                    |ts| Utc.timestamp_opt(ts, 0),
102                )
103                .single()
104                .zip(
105                    prev.timestamp
106                        .and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
107                )
108                .map(|(curr, prev)| (curr.date_naive() - prev.date_naive()).num_days())
109        } else {
110            tracing::trace!("Previous release not found");
111            None
112        };
113        Self {
114            commit_count,
115            commits_timespan,
116            conventional_commit_count,
117            links,
118            days_passed_since_last_release,
119        }
120    }
121}
122
123#[cfg(test)]
124mod test {
125    use pretty_assertions::assert_eq;
126    use regex::Regex;
127
128    use super::*;
129    use crate::commit::{Commit, Signature};
130    use crate::config::LinkParser;
131    use crate::error::Result;
132    use crate::release::Release;
133    #[test]
134    fn from_release() -> Result<()> {
135        fn find_count(v: &[LinkCount], text: &str, href: &str) -> Option<usize> {
136            v.iter()
137                .find(|l| l.text == text && l.href == href)
138                .map(|l| l.count)
139        }
140        let link_parsers = vec![
141            LinkParser {
142                pattern: Regex::new("RFC(\\d+)")?,
143                href: String::from("rfc://$1"),
144                text: None,
145            },
146            LinkParser {
147                pattern: Regex::new("#(\\d+)")?,
148                href: String::from("https://github.com/$1"),
149                text: None,
150            },
151        ];
152        let unconventional_commits = vec![
153            Commit {
154                id: String::from("123123"),
155                message: String::from("add feature"),
156                committer: Signature {
157                    name: Some(String::from("John Doe")),
158                    email: Some(String::from("john@doe.com")),
159                    timestamp: 1_649_201_111,
160                },
161                ..Default::default()
162            },
163            Commit {
164                id: String::from("123123"),
165                message: String::from("fix feature"),
166                committer: Signature {
167                    name: Some(String::from("John Doe")),
168                    email: Some(String::from("john@doe.com")),
169                    timestamp: 1_649_201_112,
170                },
171                ..Default::default()
172            },
173            Commit {
174                id: String::from("123123"),
175                message: String::from("refactor feature"),
176                committer: Signature {
177                    name: Some(String::from("John Doe")),
178                    email: Some(String::from("john@doe.com")),
179                    timestamp: 1_649_201_113,
180                },
181                ..Default::default()
182            },
183            Commit {
184                id: String::from("123123"),
185                message: String::from("add docs for RFC456-related feature"),
186                committer: Signature {
187                    name: Some(String::from("John Doe")),
188                    email: Some(String::from("john@doe.com")),
189                    timestamp: 1_649_201_114,
190                },
191                ..Default::default()
192            },
193        ];
194        let conventional_commits = vec![
195            Commit {
196                id: String::from("123123"),
197                message: String::from("perf: improve feature performance, fixes #455"),
198                committer: Signature {
199                    name: Some(String::from("John Doe")),
200                    email: Some(String::from("john@doe.com")),
201                    timestamp: 1_649_287_515,
202                },
203                ..Default::default()
204            },
205            Commit {
206                id: String::from("123123"),
207                message: String::from("style(schema): fix feature schema"),
208                committer: Signature {
209                    name: Some(String::from("John Doe")),
210                    email: Some(String::from("john@doe.com")),
211                    timestamp: 1_649_287_516,
212                },
213                ..Default::default()
214            },
215            Commit {
216                id: String::from("123123"),
217                message: String::from("test: add unit tests for RFC456-related feature"),
218                committer: Signature {
219                    name: Some(String::from("John Doe")),
220                    email: Some(String::from("john@doe.com")),
221                    timestamp: 1_649_287_517,
222                },
223                ..Default::default()
224            },
225        ];
226        let commits: Vec<Commit> = [unconventional_commits.clone(), conventional_commits.clone()]
227            .concat()
228            .into_iter()
229            .map(|c| c.parse_links(&link_parsers))
230            .map(|c| c.clone().into_conventional().unwrap_or(c))
231            .collect();
232        let release = Release {
233            commits,
234            timestamp: Some(1_649_373_910),
235            previous: Some(Box::new(Release {
236                timestamp: Some(1_649_201_110),
237                ..Default::default()
238            })),
239            repository: Some(String::from("/root/repo")),
240            ..Default::default()
241        };
242
243        let statistics = Statistics::from(&release);
244        assert_eq!(release.commits.len(), statistics.commit_count);
245        assert_eq!(Some(1), statistics.commits_timespan);
246        assert_eq!(
247            conventional_commits.len(),
248            statistics.conventional_commit_count
249        );
250        assert_eq!(
251            Some(2),
252            find_count(&statistics.links, "RFC456", "rfc://456")
253        );
254        assert_eq!(
255            Some(1),
256            find_count(&statistics.links, "#455", "https://github.com/455")
257        );
258        assert_eq!(Some(2), statistics.days_passed_since_last_release);
259
260        let commits = vec![Commit {
261            id: String::from("123123"),
262            message: String::from("add feature"),
263            committer: Signature {
264                name: Some(String::from("John Doe")),
265                email: Some(String::from("john@doe.com")),
266                timestamp: 1_649_201_111,
267            },
268            ..Default::default()
269        }];
270        let release = Release {
271            commits,
272            timestamp: Some(1_649_373_910),
273            previous: Some(Box::new(Release {
274                timestamp: Some(1_649_201_110),
275                ..Default::default()
276            })),
277            repository: Some(String::from("/root/repo")),
278            ..Default::default()
279        };
280
281        let statistics = Statistics::from(&release);
282        assert_eq!(None, statistics.commits_timespan);
283
284        let commits = vec![];
285        let release = Release {
286            commits,
287            timestamp: Some(1_649_373_910),
288            previous: None,
289            repository: Some(String::from("/root/repo")),
290            ..Default::default()
291        };
292
293        let statistics = Statistics::from(&release);
294        assert_eq!(None, statistics.days_passed_since_last_release);
295
296        Ok(())
297    }
298}