rigspark_core/
freshness.rs1use 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}