1use std::collections::HashMap;
2
3use chrono::{TimeZone, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::release::Release;
7
8pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["commit.statistics"];
10
11#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct LinkCount {
16 pub text: String,
18 pub href: String,
20 pub count: usize,
22}
23
24#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Statistics {
27 pub commit_count: usize,
29 #[serde(skip_serializing_if = "Option::is_none")]
32 pub commits_timespan: Option<i64>,
33 pub conventional_commit_count: usize,
36 pub links: Vec<LinkCount>,
38 #[serde(skip_serializing_if = "Option::is_none")]
41 pub days_passed_since_last_release: Option<i64>,
42}
43
44impl From<&Release<'_>> for Statistics {
45 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}