Skip to main content

skipper_core/
report.rs

1use std::collections::HashMap;
2use std::io::Write;
3
4use chrono::{DateTime, Duration, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8
9/// Quarantine-debt summary emitted at the end of a run.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Report {
12    pub generated_at: String,
13    pub currently_suppressed: usize,
14    pub suppressed_this_run: Vec<String>,
15    pub expiring_this_week: usize,
16    pub re_enabled_this_run: Vec<String>,
17    pub quarantine_days_debt: i64,
18}
19
20/// `entries`: resolver state; `suppressed`: IDs skipped in this run;
21/// `re_enabled`: IDs that ran although listed with an already expired date.
22pub fn build_report(
23    entries: &HashMap<String, Option<DateTime<Utc>>>,
24    suppressed: &[String],
25    re_enabled: &[String],
26) -> Report {
27    let now = Utc::now();
28    let week = now + Duration::days(7);
29    let active: Vec<DateTime<Utc>> = entries
30        .values()
31        .flatten()
32        .copied()
33        .filter(|d| *d > now)
34        .collect();
35    let sorted = |v: &[String]| {
36        let mut v = v.to_vec();
37        v.sort();
38        v.dedup();
39        v
40    };
41    Report {
42        generated_at: now.to_rfc3339(),
43        currently_suppressed: active.len(),
44        suppressed_this_run: sorted(suppressed),
45        expiring_this_week: active.iter().filter(|d| **d <= week).count(),
46        re_enabled_this_run: sorted(re_enabled),
47        quarantine_days_debt: active.iter().map(|d| (*d - now).num_days().max(0)).sum(),
48    }
49}
50
51pub fn build_markdown_summary(r: &Report) -> String {
52    let mut md = format!(
53        "## Skipper Quarantine Report\n\n\
54         | Metric | Value |\n\
55         |--------|-------|\n\
56         | Currently suppressed | {} |\n\
57         | Suppressed this run | {} |\n\
58         | Expiring this week | {} |\n\
59         | Re-enabled this run | {} |\n\
60         | Quarantine-days debt | {} |\n\n",
61        r.currently_suppressed,
62        r.suppressed_this_run.len(),
63        r.expiring_this_week,
64        r.re_enabled_this_run.len(),
65        r.quarantine_days_debt
66    );
67    for (title, ids) in [
68        ("Suppressed this run", &r.suppressed_this_run),
69        ("Re-enabled this run", &r.re_enabled_this_run),
70    ] {
71        if !ids.is_empty() {
72            md.push_str(&format!("### {title}\n\n"));
73            for id in ids {
74                md.push_str(&format!("- `{id}`\n"));
75            }
76            md.push('\n');
77        }
78    }
79    md
80}
81
82/// Append the markdown to `$GITHUB_STEP_SUMMARY` (else print it to stdout)
83/// and write `skipper-report.json` in the current directory.
84pub fn emit_summary(r: &Report) -> Result<()> {
85    let md = build_markdown_summary(r);
86    match std::env::var("GITHUB_STEP_SUMMARY")
87        .ok()
88        .filter(|s| !s.is_empty())
89    {
90        Some(path) => std::fs::OpenOptions::new()
91            .create(true)
92            .append(true)
93            .open(path)?
94            .write_all(md.as_bytes())?,
95        None => println!("{md}"),
96    }
97    std::fs::write("skipper-report.json", serde_json::to_vec_pretty(r)?)?;
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn counts_active_quarantine() {
107        let now = Utc::now();
108        let entries = HashMap::from([
109            (
110                "a".to_string(),
111                Some(now + Duration::days(3) + Duration::hours(1)),
112            ),
113            (
114                "b".to_string(),
115                Some(now + Duration::days(30) + Duration::hours(1)),
116            ),
117            ("c".to_string(), Some(now - Duration::days(1))),
118            ("d".to_string(), None),
119        ]);
120        let r = build_report(&entries, &["b".into(), "a".into()], &["c".into()]);
121        assert_eq!(r.currently_suppressed, 2);
122        assert_eq!(r.expiring_this_week, 1);
123        assert_eq!(r.quarantine_days_debt, 33);
124        assert_eq!(r.suppressed_this_run, ["a", "b"]);
125        let md = build_markdown_summary(&r);
126        assert!(md.starts_with("## Skipper Quarantine Report"));
127        assert!(md.contains("### Re-enabled this run\n\n- `c`"));
128    }
129}