1use std::collections::HashMap;
7
8use serde::Serialize;
9use time::Date;
10
11use super::{Adr, Status};
12
13#[derive(Debug, Clone, Default, Serialize)]
15pub struct AdrStatistics {
16 pub total_count: usize,
18 pub by_status: HashMap<String, usize>,
20 pub by_category: HashMap<String, usize>,
22 pub by_author: HashMap<String, usize>,
24 pub by_tag: HashMap<String, usize>,
26 pub by_technology: HashMap<String, usize>,
28 pub by_project: HashMap<String, usize>,
30 pub by_year: HashMap<i32, usize>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub earliest_date: Option<Date>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub latest_date: Option<Date>,
38}
39
40impl AdrStatistics {
41 #[must_use]
43 pub fn from_adrs(adrs: &[Adr]) -> Self {
44 let mut stats = Self {
45 total_count: adrs.len(),
46 ..Self::default()
47 };
48
49 for status in Status::all() {
51 stats.by_status.insert(status.as_str().to_string(), 0);
52 }
53
54 let mut earliest: Option<Date> = None;
55 let mut latest: Option<Date> = None;
56
57 for adr in adrs {
58 *stats
60 .by_status
61 .entry(adr.status().as_str().to_string())
62 .or_insert(0) += 1;
63
64 if !adr.category().is_empty() {
66 *stats
67 .by_category
68 .entry(adr.category().to_string())
69 .or_insert(0) += 1;
70 }
71
72 if !adr.author().is_empty() {
74 *stats.by_author.entry(adr.author().to_string()).or_insert(0) += 1;
75 }
76
77 for tag in adr.tags() {
79 *stats.by_tag.entry(tag.clone()).or_insert(0) += 1;
80 }
81
82 for tech in adr.technologies() {
84 *stats.by_technology.entry(tech.clone()).or_insert(0) += 1;
85 }
86
87 if !adr.project().is_empty() {
89 *stats
90 .by_project
91 .entry(adr.project().to_string())
92 .or_insert(0) += 1;
93 }
94
95 if let Some(created) = adr.created() {
97 *stats.by_year.entry(created.year()).or_insert(0) += 1;
99
100 if earliest.is_none_or(|e| created < e) {
102 earliest = Some(created);
103 }
104 if latest.is_none_or(|l| created > l) {
105 latest = Some(created);
106 }
107 }
108 }
109
110 stats.earliest_date = earliest;
111 stats.latest_date = latest;
112
113 stats
114 }
115
116 pub fn top_n<S: AsRef<str>>(counts: &HashMap<S, usize>, n: usize) -> Vec<(&str, usize)> {
118 let mut items: Vec<_> = counts.iter().map(|(k, &v)| (k.as_ref(), v)).collect();
119 items.sort_by(|a, b| b.1.cmp(&a.1));
120 items.truncate(n);
121 items
122 }
123
124 #[must_use]
126 pub fn summary(&self) -> String {
127 use std::fmt::Write;
128
129 let mut output = String::new();
130 let _ = writeln!(output, "ADR Statistics");
131 let _ = writeln!(output, "==============");
132 let _ = writeln!(output, "Total: {} records", self.total_count);
133
134 let mut status_parts: Vec<String> = Vec::new();
136 for status in Status::all() {
137 let key = status.as_str().to_string();
138 let count = self.by_status.get(&key).copied().unwrap_or(0);
139 if count > 0 {
140 status_parts.push(format!("{} ({})", status, count));
141 }
142 }
143 if !status_parts.is_empty() {
144 let _ = writeln!(output, "By Status: {}", status_parts.join(", "));
145 }
146
147 if !self.by_category.is_empty() {
149 let top = Self::top_n(&self.by_category, 5);
150 let parts: Vec<String> = top.iter().map(|(k, v)| format!("{k} ({v})")).collect();
151 let _ = writeln!(output, "By Category: {}", parts.join(", "));
152 }
153
154 if !self.by_author.is_empty() {
156 let top = Self::top_n(&self.by_author, 5);
157 let parts: Vec<String> = top.iter().map(|(k, v)| format!("{k} ({v})")).collect();
158 let _ = writeln!(output, "Authors: {}", parts.join(", "));
159 }
160
161 match (&self.earliest_date, &self.latest_date) {
163 (Some(earliest), Some(latest)) => {
164 let _ = writeln!(output, "Date Range: {earliest} -> {latest}");
165 },
166 _ => {},
167 }
168
169 output
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::domain::{AdrId, Frontmatter};
177 use std::path::PathBuf;
178 use time::macros::date;
179
180 fn create_test_adr(title: &str, status: Status, category: &str) -> Adr {
181 let frontmatter = Frontmatter::new(title)
182 .with_status(status)
183 .with_category(category)
184 .with_created(date!(2025 - 01 - 15));
185
186 Adr::new(
187 AdrId::new("test"),
188 "test.md".to_string(),
189 PathBuf::from("test.md"),
190 frontmatter,
191 String::new(),
192 String::new(),
193 String::new(),
194 )
195 }
196
197 #[test]
198 fn test_statistics_empty() {
199 let stats = AdrStatistics::from_adrs(&[]);
200 assert_eq!(stats.total_count, 0);
201 }
202
203 #[test]
204 fn test_statistics_by_status() {
205 let adrs = vec![
206 create_test_adr("ADR 1", Status::Accepted, "arch"),
207 create_test_adr("ADR 2", Status::Accepted, "api"),
208 create_test_adr("ADR 3", Status::Proposed, "arch"),
209 ];
210
211 let stats = AdrStatistics::from_adrs(&adrs);
212
213 assert_eq!(stats.total_count, 3);
214 assert_eq!(stats.by_status.get("accepted"), Some(&2));
215 assert_eq!(stats.by_status.get("proposed"), Some(&1));
216 }
217
218 #[test]
219 fn test_statistics_by_category() {
220 let adrs = vec![
221 create_test_adr("ADR 1", Status::Accepted, "architecture"),
222 create_test_adr("ADR 2", Status::Accepted, "architecture"),
223 create_test_adr("ADR 3", Status::Proposed, "api"),
224 ];
225
226 let stats = AdrStatistics::from_adrs(&adrs);
227
228 assert_eq!(stats.by_category.get("architecture"), Some(&2));
229 assert_eq!(stats.by_category.get("api"), Some(&1));
230 }
231
232 #[test]
233 fn test_statistics_date_range() {
234 let mut fm1 = Frontmatter::new("Early");
235 fm1.created = Some(date!(2024 - 01 - 01));
236
237 let mut fm2 = Frontmatter::new("Late");
238 fm2.created = Some(date!(2025 - 06 - 15));
239
240 let adrs = vec![
241 Adr::new(
242 AdrId::new("1"),
243 "1.md".to_string(),
244 PathBuf::from("1.md"),
245 fm1,
246 String::new(),
247 String::new(),
248 String::new(),
249 ),
250 Adr::new(
251 AdrId::new("2"),
252 "2.md".to_string(),
253 PathBuf::from("2.md"),
254 fm2,
255 String::new(),
256 String::new(),
257 String::new(),
258 ),
259 ];
260
261 let stats = AdrStatistics::from_adrs(&adrs);
262
263 assert_eq!(stats.earliest_date, Some(date!(2024 - 01 - 01)));
264 assert_eq!(stats.latest_date, Some(date!(2025 - 06 - 15)));
265 }
266
267 #[test]
268 fn test_top_n() {
269 let mut counts = HashMap::new();
270 counts.insert("a", 10);
271 counts.insert("b", 5);
272 counts.insert("c", 20);
273 counts.insert("d", 1);
274
275 let top = AdrStatistics::top_n(&counts, 2);
276
277 assert_eq!(top.len(), 2);
278 assert_eq!(top[0], ("c", 20));
279 assert_eq!(top[1], ("a", 10));
280 }
281
282 #[test]
283 fn test_summary_format() {
284 let adrs = vec![create_test_adr(
285 "Test ADR",
286 Status::Accepted,
287 "architecture",
288 )];
289
290 let stats = AdrStatistics::from_adrs(&adrs);
291 let summary = stats.summary();
292
293 assert!(summary.contains("ADR Statistics"));
294 assert!(summary.contains("Total: 1"));
295 assert!(summary.contains("accepted"));
296 }
297
298 #[test]
299 fn test_statistics_by_author() {
300 let fm1 = Frontmatter::new("ADR 1")
301 .with_status(Status::Accepted)
302 .with_author("Alice")
303 .with_created(date!(2025 - 01 - 15));
304
305 let fm2 = Frontmatter::new("ADR 2")
306 .with_status(Status::Proposed)
307 .with_author("Bob")
308 .with_created(date!(2025 - 01 - 15));
309
310 let fm3 = Frontmatter::new("ADR 3")
311 .with_status(Status::Accepted)
312 .with_author("Alice")
313 .with_created(date!(2025 - 01 - 15));
314
315 let adrs = vec![
316 Adr::new(
317 AdrId::new("1"),
318 "1.md".to_string(),
319 PathBuf::from("1.md"),
320 fm1,
321 String::new(),
322 String::new(),
323 String::new(),
324 ),
325 Adr::new(
326 AdrId::new("2"),
327 "2.md".to_string(),
328 PathBuf::from("2.md"),
329 fm2,
330 String::new(),
331 String::new(),
332 String::new(),
333 ),
334 Adr::new(
335 AdrId::new("3"),
336 "3.md".to_string(),
337 PathBuf::from("3.md"),
338 fm3,
339 String::new(),
340 String::new(),
341 String::new(),
342 ),
343 ];
344
345 let stats = AdrStatistics::from_adrs(&adrs);
346
347 assert_eq!(stats.by_author.get("Alice"), Some(&2));
348 assert_eq!(stats.by_author.get("Bob"), Some(&1));
349 }
350
351 #[test]
352 fn test_statistics_by_technology() {
353 let fm1 = Frontmatter::new("ADR 1")
354 .with_status(Status::Accepted)
355 .with_technologies(vec!["rust".to_string(), "postgres".to_string()])
356 .with_created(date!(2025 - 01 - 15));
357
358 let fm2 = Frontmatter::new("ADR 2")
359 .with_status(Status::Proposed)
360 .with_technologies(vec!["rust".to_string(), "redis".to_string()])
361 .with_created(date!(2025 - 01 - 15));
362
363 let adrs = vec![
364 Adr::new(
365 AdrId::new("1"),
366 "1.md".to_string(),
367 PathBuf::from("1.md"),
368 fm1,
369 String::new(),
370 String::new(),
371 String::new(),
372 ),
373 Adr::new(
374 AdrId::new("2"),
375 "2.md".to_string(),
376 PathBuf::from("2.md"),
377 fm2,
378 String::new(),
379 String::new(),
380 String::new(),
381 ),
382 ];
383
384 let stats = AdrStatistics::from_adrs(&adrs);
385
386 assert_eq!(stats.by_technology.get("rust"), Some(&2));
387 assert_eq!(stats.by_technology.get("postgres"), Some(&1));
388 assert_eq!(stats.by_technology.get("redis"), Some(&1));
389 }
390
391 #[test]
392 fn test_statistics_by_project() {
393 let fm1 = Frontmatter::new("ADR 1")
394 .with_status(Status::Accepted)
395 .with_project("project-alpha")
396 .with_created(date!(2025 - 01 - 15));
397
398 let fm2 = Frontmatter::new("ADR 2")
399 .with_status(Status::Proposed)
400 .with_project("project-beta")
401 .with_created(date!(2025 - 01 - 15));
402
403 let fm3 = Frontmatter::new("ADR 3")
404 .with_status(Status::Accepted)
405 .with_project("project-alpha")
406 .with_created(date!(2025 - 01 - 15));
407
408 let adrs = vec![
409 Adr::new(
410 AdrId::new("1"),
411 "1.md".to_string(),
412 PathBuf::from("1.md"),
413 fm1,
414 String::new(),
415 String::new(),
416 String::new(),
417 ),
418 Adr::new(
419 AdrId::new("2"),
420 "2.md".to_string(),
421 PathBuf::from("2.md"),
422 fm2,
423 String::new(),
424 String::new(),
425 String::new(),
426 ),
427 Adr::new(
428 AdrId::new("3"),
429 "3.md".to_string(),
430 PathBuf::from("3.md"),
431 fm3,
432 String::new(),
433 String::new(),
434 String::new(),
435 ),
436 ];
437
438 let stats = AdrStatistics::from_adrs(&adrs);
439
440 assert_eq!(stats.by_project.get("project-alpha"), Some(&2));
441 assert_eq!(stats.by_project.get("project-beta"), Some(&1));
442 }
443
444 #[test]
445 fn test_statistics_by_tag() {
446 let fm1 = Frontmatter::new("ADR 1")
447 .with_status(Status::Accepted)
448 .with_tags(vec!["database".to_string(), "performance".to_string()])
449 .with_created(date!(2025 - 01 - 15));
450
451 let fm2 = Frontmatter::new("ADR 2")
452 .with_status(Status::Proposed)
453 .with_tags(vec!["database".to_string(), "security".to_string()])
454 .with_created(date!(2025 - 01 - 15));
455
456 let adrs = vec![
457 Adr::new(
458 AdrId::new("1"),
459 "1.md".to_string(),
460 PathBuf::from("1.md"),
461 fm1,
462 String::new(),
463 String::new(),
464 String::new(),
465 ),
466 Adr::new(
467 AdrId::new("2"),
468 "2.md".to_string(),
469 PathBuf::from("2.md"),
470 fm2,
471 String::new(),
472 String::new(),
473 String::new(),
474 ),
475 ];
476
477 let stats = AdrStatistics::from_adrs(&adrs);
478
479 assert_eq!(stats.by_tag.get("database"), Some(&2));
480 assert_eq!(stats.by_tag.get("performance"), Some(&1));
481 assert_eq!(stats.by_tag.get("security"), Some(&1));
482 }
483
484 #[test]
485 fn test_summary_with_all_fields() {
486 let fm1 = Frontmatter::new("ADR 1")
487 .with_status(Status::Accepted)
488 .with_category("architecture")
489 .with_author("Alice")
490 .with_created(date!(2025 - 01 - 15));
491
492 let fm2 = Frontmatter::new("ADR 2")
493 .with_status(Status::Proposed)
494 .with_category("api")
495 .with_author("Bob")
496 .with_created(date!(2025 - 06 - 20));
497
498 let adrs = vec![
499 Adr::new(
500 AdrId::new("1"),
501 "1.md".to_string(),
502 PathBuf::from("1.md"),
503 fm1,
504 String::new(),
505 String::new(),
506 String::new(),
507 ),
508 Adr::new(
509 AdrId::new("2"),
510 "2.md".to_string(),
511 PathBuf::from("2.md"),
512 fm2,
513 String::new(),
514 String::new(),
515 String::new(),
516 ),
517 ];
518
519 let stats = AdrStatistics::from_adrs(&adrs);
520 let summary = stats.summary();
521
522 assert!(summary.contains("Total: 2 records"));
523 assert!(summary.contains("By Category:"));
524 assert!(summary.contains("Authors:"));
525 assert!(summary.contains("Date Range:"));
526 }
527}