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