1use std::collections::HashMap;
2
3use chrono::{TimeZone, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::release::Release;
7
8#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct LinkCount {
13 pub text: String,
15 pub href: String,
17 pub count: usize,
19}
20
21#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Statistics {
24 pub commit_count: usize,
26 #[serde(skip_serializing_if = "Option::is_none")]
29 pub commits_timespan: Option<i64>,
30 pub conventional_commit_count: usize,
33 pub links: Vec<LinkCount>,
35 #[serde(skip_serializing_if = "Option::is_none")]
38 pub days_passed_since_last_release: Option<i64>,
39}
40
41impl From<&Release<'_>> for Statistics {
42 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}