Skip to main content

rigspark_core/
freshness.rs

1use crate::enrich::EnrichDiff;
2use serde::{Deserialize, Serialize};
3use time::{OffsetDateTime, format_description::well_known::Rfc3339};
4
5pub const STALE_AFTER_DAYS: u64 = 7;
6
7#[derive(Deserialize, Serialize)]
8#[serde(deny_unknown_fields)]
9pub struct DriftCounts {
10    pub added: usize,
11    pub updated: usize,
12    pub removed: usize,
13    pub skipped: usize,
14    pub capped: usize,
15}
16
17#[derive(PartialEq, Eq, Deserialize, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Status {
20    Fresh,
21    Stale,
22}
23
24#[derive(Deserialize, Serialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct Report {
27    pub generated_at: String,
28    pub age_days: u64,
29    pub stale_after_days: u64,
30    pub status: Status,
31    pub has_drift: bool,
32    pub drift: DriftCounts,
33    pub needs_attention: bool,
34    pub reasons: Vec<String>,
35}
36
37pub fn evaluate(
38    generated_at: &str,
39    diff: &EnrichDiff,
40    now: &str,
41    stale_after_days: u64,
42) -> Result<Report, time::error::Parse> {
43    let generated = OffsetDateTime::parse(generated_at, &Rfc3339)?;
44    let now = OffsetDateTime::parse(now, &Rfc3339)?;
45    let age_days = u64::try_from((now - generated).whole_days()).unwrap_or(0);
46    let drift = DriftCounts {
47        added: diff.added.len(),
48        updated: diff.updated.len(),
49        removed: diff.removed.len(),
50        skipped: diff.skipped.len(),
51        capped: diff.capped.len(),
52    };
53    let has_drift = drift.added > 0 || drift.updated > 0 || drift.removed > 0;
54    let status = if age_days > stale_after_days {
55        Status::Stale
56    } else {
57        Status::Fresh
58    };
59    let mut reasons = Vec::new();
60    if status == Status::Stale {
61        reasons.push(format!(
62            "catalog is {age_days} days old (stale after {stale_after_days})"
63        ));
64    }
65    if has_drift {
66        reasons.push(format!(
67            "registry snapshot yields drift: +{} ~{} -{}",
68            drift.added, drift.updated, drift.removed
69        ));
70    }
71    Ok(Report {
72        generated_at: generated_at.into(),
73        age_days,
74        stale_after_days,
75        needs_attention: status == Status::Stale || has_drift,
76        status,
77        has_drift,
78        drift,
79        reasons,
80    })
81}
82
83pub fn format_report(report: &Report) -> String {
84    let status = if report.status == Status::Stale {
85        "stale"
86    } else {
87        "fresh"
88    };
89    let attention = if report.needs_attention { "yes" } else { "no" };
90    let mut text = format!(
91        "Catalog freshness\n  generated: {}\n  age:       {} day(s) (stale after {})\n  status:    {status}\n  drift:     added={} updated={} removed={} skipped={} capped={}\n  attention: {attention}",
92        report.generated_at,
93        report.age_days,
94        report.stale_after_days,
95        report.drift.added,
96        report.drift.updated,
97        report.drift.removed,
98        report.drift.skipped,
99        report.drift.capped
100    );
101    if !report.reasons.is_empty() {
102        text.push_str("\n  reasons:");
103        for reason in &report.reasons {
104            text.push_str("\n    - ");
105            text.push_str(reason);
106        }
107    }
108    text
109}