Skip to main content

timebomb/
stats.rs

1use crate::annotation::{Fuse, Status};
2use crate::output::OutputFormat;
3use colored::Colorize;
4use serde::Serialize;
5use std::collections::HashMap;
6
7/// One row in the owner breakdown.
8#[derive(Debug, Clone, Serialize)]
9pub struct OwnerRow {
10    pub owner: String,
11    pub total: usize,
12    pub detonated: usize,
13    pub ticking: usize,
14    pub inert: usize,
15}
16
17/// One row in the tag breakdown.
18#[derive(Debug, Clone, Serialize)]
19pub struct TagRow {
20    pub tag: String,
21    pub total: usize,
22    pub detonated: usize,
23    pub ticking: usize,
24    pub inert: usize,
25}
26
27/// One row in the month timeline breakdown.
28#[derive(Debug, Clone, Serialize)]
29pub struct MonthRow {
30    /// Expiry month in `YYYY-MM` format.
31    pub month: String,
32    pub total: usize,
33    pub detonated: usize,
34    pub ticking: usize,
35    pub inert: usize,
36}
37
38/// The complete stats result.
39#[derive(Debug, Serialize)]
40pub struct StatsResult {
41    pub total_fuses: usize,
42    pub total_detonated: usize,
43    pub total_ticking: usize,
44    pub total_inert: usize,
45    pub by_owner: Vec<OwnerRow>,
46    pub by_tag: Vec<TagRow>,
47    pub by_month: Vec<MonthRow>,
48}
49
50/// Compute stats from a slice of fuses.
51/// Rows are sorted: detonated count descending, then total descending, then name ascending.
52/// Month rows are sorted chronologically (ascending).
53pub fn compute_stats(fuses: &[Fuse]) -> StatsResult {
54    let mut owner_map: HashMap<String, OwnerRow> = HashMap::new();
55    let mut tag_map: HashMap<String, TagRow> = HashMap::new();
56    let mut month_map: HashMap<String, MonthRow> = HashMap::new();
57
58    let mut total_fuses = 0usize;
59    let mut total_detonated = 0usize;
60    let mut total_ticking = 0usize;
61    let mut total_inert = 0usize;
62
63    for fuse in fuses {
64        // Use as_deref to avoid cloning the Option<String> before the entry() call.
65        let owner_key = fuse.owner.as_deref().unwrap_or("(unowned)");
66
67        total_fuses += 1;
68
69        let (is_detonated, is_ticking, is_inert) = match fuse.status {
70            Status::Detonated => {
71                total_detonated += 1;
72                (1usize, 0usize, 0usize)
73            }
74            Status::Ticking => {
75                total_ticking += 1;
76                (0, 1, 0)
77            }
78            Status::Inert => {
79                total_inert += 1;
80                (0, 0, 1)
81            }
82        };
83
84        // Update owner row
85        let orow = owner_map
86            .entry(owner_key.to_string())
87            .or_insert_with(|| OwnerRow {
88                owner: owner_key.to_string(),
89                total: 0,
90                detonated: 0,
91                ticking: 0,
92                inert: 0,
93            });
94        orow.total += 1;
95        orow.detonated += is_detonated;
96        orow.ticking += is_ticking;
97        orow.inert += is_inert;
98
99        // Update tag row
100        let trow = tag_map.entry(fuse.tag.clone()).or_insert_with(|| TagRow {
101            tag: fuse.tag.clone(),
102            total: 0,
103            detonated: 0,
104            ticking: 0,
105            inert: 0,
106        });
107        trow.total += 1;
108        trow.detonated += is_detonated;
109        trow.ticking += is_ticking;
110        trow.inert += is_inert;
111
112        // Update month row — group by expiry month (YYYY-MM).
113        // The key is moved into entry(), so or_insert_with recomputes the format string
114        // for the MonthRow.month field rather than cloning or pre-computing it.
115        let mrow = month_map
116            .entry(fuse.date.format("%Y-%m").to_string())
117            .or_insert_with(|| MonthRow {
118                month: fuse.date.format("%Y-%m").to_string(),
119                total: 0,
120                detonated: 0,
121                ticking: 0,
122                inert: 0,
123            });
124        mrow.total += 1;
125        mrow.detonated += is_detonated;
126        mrow.ticking += is_ticking;
127        mrow.inert += is_inert;
128    }
129
130    let mut by_owner: Vec<OwnerRow> = owner_map.into_values().collect();
131    by_owner.sort_by(|a, b| {
132        b.detonated
133            .cmp(&a.detonated)
134            .then(b.total.cmp(&a.total))
135            .then(a.owner.cmp(&b.owner))
136    });
137
138    let mut by_tag: Vec<TagRow> = tag_map.into_values().collect();
139    by_tag.sort_by(|a, b| {
140        b.detonated
141            .cmp(&a.detonated)
142            .then(b.total.cmp(&a.total))
143            .then(a.tag.cmp(&b.tag))
144    });
145
146    // Month rows: sorted chronologically ascending by YYYY-MM string.
147    let mut by_month: Vec<MonthRow> = month_map.into_values().collect();
148    by_month.sort_by(|a, b| a.month.cmp(&b.month));
149
150    StatsResult {
151        total_fuses,
152        total_detonated,
153        total_ticking,
154        total_inert,
155        by_owner,
156        by_tag,
157        by_month,
158    }
159}
160
161/// Truncate a name to fit within 20 chars (left-aligned).
162/// If the name is longer than 18 chars, truncate to 18 and append "..".
163/// Uses char-safe truncation to avoid panicking on multi-byte UTF-8 characters.
164fn truncate_name(name: &str) -> String {
165    if name.chars().count() > 18 {
166        // Find the byte offset of the 18th char boundary so we can slice safely.
167        let end = name
168            .char_indices()
169            .nth(18)
170            .map(|(i, _)| i)
171            .unwrap_or(name.len());
172        format!("{}..", &name[..end])
173    } else {
174        name.to_string()
175    }
176}
177
178/// Whether color output should be enabled (respects NO_COLOR env var).
179fn color_enabled() -> bool {
180    std::env::var("NO_COLOR").is_err()
181}
182
183/// Format a detonated count cell, optionally in red.
184fn fmt_detonated(count: usize, use_color: bool) -> String {
185    let s = format!("{:>8}", count);
186    if use_color && count > 0 {
187        s.red().to_string()
188    } else {
189        s
190    }
191}
192
193/// Print stats in terminal (human-readable table) format.
194pub fn print_stats_terminal(result: &StatsResult) {
195    let use_color = color_enabled();
196
197    // BY OWNER
198    println!("BY OWNER");
199    println!("--------");
200    println!(
201        "{:<20}{:>8}{:>10}{:>8}{:>8}",
202        "OWNER", "TOTAL", "DETONATED", "TICKING", "INERT"
203    );
204    for row in &result.by_owner {
205        let name = truncate_name(&row.owner);
206        println!(
207            "{:<20}{:>8}{}{:>8}{:>8}",
208            name,
209            row.total,
210            fmt_detonated(row.detonated, use_color),
211            row.ticking,
212            row.inert,
213        );
214    }
215
216    println!();
217
218    // BY TAG
219    println!("BY TAG");
220    println!("------");
221    println!(
222        "{:<20}{:>8}{:>10}{:>8}{:>8}",
223        "TAG", "TOTAL", "DETONATED", "TICKING", "INERT"
224    );
225    for row in &result.by_tag {
226        let name = truncate_name(&row.tag);
227        println!(
228            "{:<20}{:>8}{}{:>8}{:>8}",
229            name,
230            row.total,
231            fmt_detonated(row.detonated, use_color),
232            row.ticking,
233            row.inert,
234        );
235    }
236
237    println!();
238    println!(
239        "{} fuse(s) total · {} detonated · {} ticking · {} inert",
240        result.total_fuses, result.total_detonated, result.total_ticking, result.total_inert,
241    );
242}
243
244/// Print stats as JSON.
245pub fn print_stats_json(result: &StatsResult) {
246    let json = serde_json::to_string_pretty(result).expect("Failed to serialize stats to JSON");
247    println!("{}", json);
248}
249
250/// Print stats in GitHub Actions format.
251pub fn print_stats_github(result: &StatsResult) {
252    for row in &result.by_owner {
253        if row.detonated > 0 {
254            println!(
255                "::warning ::OWNER {} has {} detonated fuse(s)",
256                row.owner, row.detonated
257            );
258        }
259    }
260    for row in &result.by_tag {
261        if row.detonated > 0 {
262            println!(
263                "::warning ::TAG {} has {} detonated fuse(s)",
264                row.tag, row.detonated
265            );
266        }
267    }
268}
269
270/// Print the month timeline breakdown in terminal format.
271pub fn print_stats_month_terminal(result: &StatsResult) {
272    let use_color = color_enabled();
273    println!("BY MONTH");
274    println!("--------");
275    println!(
276        "{:<20}{:>8}{:>10}{:>8}{:>8}",
277        "MONTH", "TOTAL", "DETONATED", "TICKING", "INERT"
278    );
279    for row in &result.by_month {
280        println!(
281            "{:<20}{:>8}{}{:>8}{:>8}",
282            row.month,
283            row.total,
284            fmt_detonated(row.detonated, use_color),
285            row.ticking,
286            row.inert,
287        );
288    }
289    println!();
290    println!(
291        "{} fuse(s) total · {} detonated · {} ticking · {} inert",
292        result.total_fuses, result.total_detonated, result.total_ticking, result.total_inert,
293    );
294}
295
296/// Top-level dispatch.
297pub fn print_stats(result: &StatsResult, format: &OutputFormat) {
298    match format {
299        OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
300            print_stats_terminal(result)
301        }
302        OutputFormat::Json => print_stats_json(result),
303        OutputFormat::GitHub => print_stats_github(result),
304    }
305}
306
307/// Top-level dispatch for month breakdown.
308pub fn print_stats_month(result: &StatsResult, format: &OutputFormat) {
309    match format {
310        OutputFormat::Terminal | OutputFormat::Csv | OutputFormat::Table => {
311            print_stats_month_terminal(result)
312        }
313        OutputFormat::Json => print_stats_json(result),
314        OutputFormat::GitHub => {
315            for row in &result.by_month {
316                if row.detonated > 0 {
317                    println!(
318                        "::warning ::MONTH {} has {} detonated fuse(s)",
319                        row.month, row.detonated
320                    );
321                }
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use chrono::NaiveDate;
331    use std::path::PathBuf;
332
333    fn make_fuse(tag: &str, owner: Option<&str>, status: Status) -> Fuse {
334        let date = match status {
335            Status::Detonated => NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
336            Status::Ticking => NaiveDate::from_ymd_opt(2025, 6, 10).unwrap(),
337            Status::Inert => NaiveDate::from_ymd_opt(2099, 1, 1).unwrap(),
338        };
339        Fuse {
340            file: PathBuf::from("src/foo.rs"),
341            line: 1,
342            tag: tag.to_string(),
343            date,
344            owner: owner.map(|s| s.to_string()),
345            message: "test message".to_string(),
346            status,
347            blamed_owner: None,
348        }
349    }
350
351    #[test]
352    fn test_compute_stats_empty() {
353        let result = compute_stats(&[]);
354        assert_eq!(result.total_fuses, 0);
355        assert_eq!(result.total_detonated, 0);
356        assert_eq!(result.total_ticking, 0);
357        assert_eq!(result.total_inert, 0);
358        assert!(result.by_owner.is_empty());
359        assert!(result.by_tag.is_empty());
360        assert!(result.by_month.is_empty());
361    }
362
363    #[test]
364    fn test_compute_stats_single_detonated() {
365        let fuses = vec![make_fuse("TODO", Some("alice"), Status::Detonated)];
366        let result = compute_stats(&fuses);
367        assert_eq!(result.total_fuses, 1);
368        assert_eq!(result.total_detonated, 1);
369        assert_eq!(result.total_ticking, 0);
370        assert_eq!(result.total_inert, 0);
371
372        assert_eq!(result.by_owner.len(), 1);
373        assert_eq!(result.by_owner[0].detonated, 1);
374
375        assert_eq!(result.by_tag.len(), 1);
376        assert_eq!(result.by_tag[0].detonated, 1);
377    }
378
379    #[test]
380    fn test_compute_stats_unowned() {
381        let fuses = vec![make_fuse("TODO", None, Status::Inert)];
382        let result = compute_stats(&fuses);
383        assert_eq!(result.by_owner.len(), 1);
384        assert_eq!(result.by_owner[0].owner, "(unowned)");
385    }
386
387    #[test]
388    fn test_compute_stats_owner_grouping() {
389        let fuses = vec![
390            make_fuse("TODO", Some("alice"), Status::Inert),
391            make_fuse("FIXME", Some("alice"), Status::Detonated),
392        ];
393        let result = compute_stats(&fuses);
394        assert_eq!(result.by_owner.len(), 1);
395        assert_eq!(result.by_owner[0].owner, "alice");
396        assert_eq!(result.by_owner[0].total, 2);
397    }
398
399    #[test]
400    fn test_compute_stats_tag_grouping() {
401        let fuses = vec![
402            make_fuse("TODO", Some("alice"), Status::Inert),
403            make_fuse("TODO", Some("bob"), Status::Detonated),
404        ];
405        let result = compute_stats(&fuses);
406        assert_eq!(result.by_tag.len(), 1);
407        assert_eq!(result.by_tag[0].tag, "TODO");
408        assert_eq!(result.by_tag[0].total, 2);
409    }
410
411    #[test]
412    fn test_compute_stats_sort_order() {
413        let fuses = vec![
414            make_fuse("TODO", Some("alice"), Status::Detonated),
415            make_fuse("TODO", Some("alice"), Status::Detonated),
416            make_fuse("TODO", Some("alice"), Status::Detonated),
417            make_fuse("TODO", Some("bob"), Status::Detonated),
418        ];
419        let result = compute_stats(&fuses);
420        assert_eq!(result.by_owner.len(), 2);
421        // alice has 3 detonated, bob has 1 — alice should come first
422        assert_eq!(result.by_owner[0].owner, "alice");
423        assert_eq!(result.by_owner[0].detonated, 3);
424        assert_eq!(result.by_owner[1].owner, "bob");
425        assert_eq!(result.by_owner[1].detonated, 1);
426    }
427
428    #[test]
429    fn test_owner_row_name_truncation() {
430        // A name of exactly 19 chars is longer than 18, so it should be truncated
431        let long_name = "a".repeat(19); // 19 chars > 18
432        let truncated = truncate_name(&long_name);
433        assert_eq!(truncated.len(), 20); // 18 chars + ".."
434        assert!(truncated.ends_with(".."));
435
436        // A name of exactly 18 chars should NOT be truncated
437        let exact_name = "b".repeat(18);
438        let not_truncated = truncate_name(&exact_name);
439        assert_eq!(not_truncated, exact_name);
440
441        // A name shorter than 18 chars should not be truncated
442        let short_name = "hello";
443        let not_truncated_short = truncate_name(short_name);
444        assert_eq!(not_truncated_short, short_name);
445    }
446
447    #[test]
448    fn test_print_stats_json_does_not_panic() {
449        let fuses = vec![
450            make_fuse("TODO", Some("alice"), Status::Detonated),
451            make_fuse("FIXME", None, Status::Inert),
452        ];
453        let result = compute_stats(&fuses);
454        print_stats_json(&result);
455    }
456
457    #[test]
458    fn test_print_stats_terminal_does_not_panic() {
459        let fuses = vec![
460            make_fuse("TODO", Some("alice"), Status::Detonated),
461            make_fuse("FIXME", None, Status::Ticking),
462            make_fuse("HACK", Some("bob"), Status::Inert),
463        ];
464        let result = compute_stats(&fuses);
465        print_stats_terminal(&result);
466    }
467
468    #[test]
469    fn test_print_stats_github_does_not_panic() {
470        let fuses = vec![
471            make_fuse("TODO", Some("alice"), Status::Detonated),
472            make_fuse("FIXME", None, Status::Inert),
473        ];
474        let result = compute_stats(&fuses);
475        print_stats_github(&result);
476    }
477
478    fn make_fuse_on_date(tag: &str, owner: Option<&str>, status: Status, date: NaiveDate) -> Fuse {
479        Fuse {
480            file: PathBuf::from("src/foo.rs"),
481            line: 1,
482            tag: tag.to_string(),
483            date,
484            owner: owner.map(|s| s.to_string()),
485            message: "test message".to_string(),
486            status,
487            blamed_owner: None,
488        }
489    }
490
491    #[test]
492    fn test_by_month_grouping() {
493        let fuses = vec![
494            make_fuse_on_date(
495                "TODO",
496                None,
497                Status::Detonated,
498                NaiveDate::from_ymd_opt(2020, 3, 15).unwrap(),
499            ),
500            make_fuse_on_date(
501                "FIXME",
502                None,
503                Status::Detonated,
504                NaiveDate::from_ymd_opt(2020, 3, 28).unwrap(),
505            ),
506            make_fuse_on_date(
507                "HACK",
508                None,
509                Status::Inert,
510                NaiveDate::from_ymd_opt(2099, 1, 1).unwrap(),
511            ),
512        ];
513        let result = compute_stats(&fuses);
514        assert_eq!(result.by_month.len(), 2);
515        // 2020-03 comes before 2099-01
516        assert_eq!(result.by_month[0].month, "2020-03");
517        assert_eq!(result.by_month[0].total, 2);
518        assert_eq!(result.by_month[0].detonated, 2);
519        assert_eq!(result.by_month[1].month, "2099-01");
520        assert_eq!(result.by_month[1].total, 1);
521        assert_eq!(result.by_month[1].inert, 1);
522    }
523
524    #[test]
525    fn test_by_month_sorted_chronologically() {
526        let fuses = vec![
527            make_fuse_on_date(
528                "TODO",
529                None,
530                Status::Inert,
531                NaiveDate::from_ymd_opt(2099, 6, 1).unwrap(),
532            ),
533            make_fuse_on_date(
534                "TODO",
535                None,
536                Status::Detonated,
537                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
538            ),
539            make_fuse_on_date(
540                "TODO",
541                None,
542                Status::Detonated,
543                NaiveDate::from_ymd_opt(2020, 3, 1).unwrap(),
544            ),
545        ];
546        let result = compute_stats(&fuses);
547        let months: Vec<&str> = result.by_month.iter().map(|r| r.month.as_str()).collect();
548        assert_eq!(months, vec!["2020-01", "2020-03", "2099-06"]);
549    }
550
551    #[test]
552    fn test_by_month_empty() {
553        let result = compute_stats(&[]);
554        assert!(result.by_month.is_empty());
555    }
556
557    #[test]
558    fn test_print_stats_month_terminal_does_not_panic() {
559        let fuses = vec![
560            make_fuse("TODO", Some("alice"), Status::Detonated),
561            make_fuse("FIXME", None, Status::Ticking),
562        ];
563        let result = compute_stats(&fuses);
564        print_stats_month_terminal(&result);
565    }
566}