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 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}