adrscope/domain/
stats.rs

1//! Statistics aggregation for ADR collections.
2//!
3//! This module provides types for computing and representing summary
4//! statistics about an ADR collection.
5
6use std::collections::HashMap;
7
8use serde::Serialize;
9use time::Date;
10
11use super::{Adr, Status};
12
13/// Aggregated statistics for an ADR collection.
14#[derive(Debug, Clone, Default, Serialize)]
15pub struct AdrStatistics {
16    /// Total number of ADRs.
17    pub total_count: usize,
18    /// Counts by status.
19    pub by_status: HashMap<String, usize>,
20    /// Counts by category.
21    pub by_category: HashMap<String, usize>,
22    /// Counts by author.
23    pub by_author: HashMap<String, usize>,
24    /// Counts by tag.
25    pub by_tag: HashMap<String, usize>,
26    /// Counts by technology.
27    pub by_technology: HashMap<String, usize>,
28    /// Counts by project.
29    pub by_project: HashMap<String, usize>,
30    /// Counts by year.
31    pub by_year: HashMap<i32, usize>,
32    /// Earliest created date.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub earliest_date: Option<Date>,
35    /// Latest created date.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub latest_date: Option<Date>,
38}
39
40impl AdrStatistics {
41    /// Computes statistics from a collection of ADRs.
42    #[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        // Initialize all status values with 0
50        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            // Count by status
59            *stats
60                .by_status
61                .entry(adr.status().as_str().to_string())
62                .or_insert(0) += 1;
63
64            // Count by category
65            if !adr.category().is_empty() {
66                *stats
67                    .by_category
68                    .entry(adr.category().to_string())
69                    .or_insert(0) += 1;
70            }
71
72            // Count by author
73            if !adr.author().is_empty() {
74                *stats.by_author.entry(adr.author().to_string()).or_insert(0) += 1;
75            }
76
77            // Count by tags
78            for tag in adr.tags() {
79                *stats.by_tag.entry(tag.clone()).or_insert(0) += 1;
80            }
81
82            // Count by technology
83            for tech in adr.technologies() {
84                *stats.by_technology.entry(tech.clone()).or_insert(0) += 1;
85            }
86
87            // Count by project
88            if !adr.project().is_empty() {
89                *stats
90                    .by_project
91                    .entry(adr.project().to_string())
92                    .or_insert(0) += 1;
93            }
94
95            // Track date ranges
96            if let Some(created) = adr.created() {
97                // Count by year
98                *stats.by_year.entry(created.year()).or_insert(0) += 1;
99
100                // Track earliest/latest
101                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    /// Returns the top N items from a count map, sorted by count descending.
117    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    /// Formats the statistics as a human-readable summary string.
125    #[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        // Status breakdown
135        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        // Category breakdown (top 5)
148        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        // Author breakdown (top 5)
155        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        // Date range
162        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}