1use chrono::{DateTime, Duration, NaiveDate, Utc};
6use serde::{Deserialize, Serialize};
7
8fn parse_fcc_datetime(s: &str) -> Option<DateTime<Utc>> {
10 let parts: Vec<&str> = s.split_whitespace().collect();
11 if parts.len() < 6 {
12 return None;
13 }
14
15 let month = match parts[1] {
16 "Jan" => 1,
17 "Feb" => 2,
18 "Mar" => 3,
19 "Apr" => 4,
20 "May" => 5,
21 "Jun" => 6,
22 "Jul" => 7,
23 "Aug" => 8,
24 "Sep" => 9,
25 "Oct" => 10,
26 "Nov" => 11,
27 "Dec" => 12,
28 _ => return None,
29 };
30
31 let day: u32 = parts[2].parse().ok()?;
32 let time_parts: Vec<&str> = parts[3].split(':').collect();
33 if time_parts.len() != 3 {
34 return None;
35 }
36 let hour: u32 = time_parts[0].parse().ok()?;
37 let min: u32 = time_parts[1].parse().ok()?;
38 let sec: u32 = time_parts[2].parse().ok()?;
39 let year: i32 = parts[5].parse().ok()?;
40
41 let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?;
42
43 Some(DateTime::from_naive_utc_and_offset(naive, Utc))
45}
46
47pub const DEFAULT_STALE_THRESHOLD_DAYS: i64 = 3;
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DataFreshness {
53 pub service: String,
55
56 pub last_updated: Option<DateTime<Utc>>,
58
59 pub age: Option<Duration>,
61
62 pub is_stale: bool,
64
65 pub age_display: String,
67
68 pub last_weekly_date: Option<NaiveDate>,
70
71 pub applied_patch_dates: Vec<NaiveDate>,
73
74 pub missing_patch_dates: Vec<NaiveDate>,
76}
77
78impl DataFreshness {
79 pub fn unknown(service: impl Into<String>) -> Self {
81 Self {
82 service: service.into(),
83 last_updated: None,
84 age: None,
85 is_stale: true, age_display: "unknown".to_string(),
87 last_weekly_date: None,
88 applied_patch_dates: Vec::new(),
89 missing_patch_dates: Vec::new(),
90 }
91 }
92
93 pub fn from_timestamp(
95 service: impl Into<String>,
96 timestamp: Option<&str>,
97 threshold_days: i64,
98 ) -> Self {
99 let service = service.into();
100
101 let last_updated = timestamp.and_then(|ts| {
102 DateTime::parse_from_rfc3339(ts)
104 .map(|dt| dt.with_timezone(&Utc))
105 .ok()
106 .or_else(|| {
107 chrono::NaiveDateTime::parse_from_str(
109 ts.trim_end_matches(" UTC"),
110 "%Y-%m-%d %H:%M:%S",
111 )
112 .ok()
113 .map(|ndt| DateTime::from_naive_utc_and_offset(ndt, Utc))
114 })
115 .or_else(|| {
116 parse_fcc_datetime(ts)
118 })
119 });
120
121 let now = Utc::now();
122 let age = last_updated.map(|lu| now.signed_duration_since(lu));
123 let threshold = Duration::days(threshold_days);
124 let is_stale = age.map(|a| a > threshold).unwrap_or(true);
125
126 let age_display = match age {
127 Some(a) => {
128 let days = a.num_days();
129 let hours = a.num_hours() % 24;
130 if days > 0 {
131 format!("{}d {}h", days, hours)
132 } else if hours > 0 {
133 format!("{}h", hours)
134 } else {
135 let mins = a.num_minutes();
136 format!("{}m", mins)
137 }
138 }
139 None => "unknown".to_string(),
140 };
141
142 Self {
143 service,
144 last_updated,
145 age,
146 is_stale,
147 age_display,
148 last_weekly_date: None,
149 applied_patch_dates: Vec::new(),
150 missing_patch_dates: Vec::new(),
151 }
152 }
153
154 pub fn age_days(&self) -> Option<i64> {
156 self.age.map(|a| a.num_days())
157 }
158
159 pub fn needs_weekly_update(&self) -> bool {
161 self.last_weekly_date.is_none()
163 }
164
165 pub fn has_missing_patches(&self) -> bool {
167 !self.missing_patch_dates.is_empty()
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct AppliedPatch {
174 pub service: String,
176
177 pub patch_date: NaiveDate,
179
180 pub weekday: String,
182
183 pub applied_at: DateTime<Utc>,
185
186 pub etag: Option<String>,
188
189 pub record_count: Option<usize>,
191}
192
193#[derive(Debug, Clone)]
195pub struct StalenessConfig {
196 pub threshold_days: i64,
198
199 pub warn_enabled: bool,
201
202 pub auto_update: bool,
204}
205
206impl Default for StalenessConfig {
207 fn default() -> Self {
208 Self {
209 threshold_days: DEFAULT_STALE_THRESHOLD_DAYS,
210 warn_enabled: true,
211 auto_update: false,
212 }
213 }
214}
215
216impl StalenessConfig {
217 pub fn no_warnings() -> Self {
219 Self {
220 warn_enabled: false,
221 ..Default::default()
222 }
223 }
224
225 pub fn with_auto_update() -> Self {
227 Self {
228 auto_update: true,
229 ..Default::default()
230 }
231 }
232
233 pub fn with_threshold(days: i64) -> Self {
235 Self {
236 threshold_days: days,
237 ..Default::default()
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use chrono::Datelike;
246
247 #[test]
248 fn test_freshness_from_recent_timestamp() {
249 let now = Utc::now();
250 let timestamp = now.format("%Y-%m-%d %H:%M:%S UTC").to_string();
251
252 let freshness = DataFreshness::from_timestamp("HA", Some(×tamp), 3);
253
254 assert!(!freshness.is_stale);
255 assert!(freshness.age.is_some());
256 assert!(freshness.age_days().unwrap_or(999) < 1);
257 }
258
259 #[test]
260 fn test_freshness_from_old_timestamp() {
261 let old = Utc::now() - Duration::days(5);
262 let timestamp = old.format("%Y-%m-%d %H:%M:%S UTC").to_string();
263
264 let freshness = DataFreshness::from_timestamp("HA", Some(×tamp), 3);
265
266 assert!(freshness.is_stale);
267 assert_eq!(freshness.age_days(), Some(5));
268 }
269
270 #[test]
271 fn test_freshness_unknown() {
272 let freshness = DataFreshness::from_timestamp("HA", None, 3);
273
274 assert!(freshness.is_stale);
275 assert!(freshness.last_updated.is_none());
276 assert_eq!(freshness.age_display, "unknown");
277 }
278
279 #[test]
280 fn test_staleness_config_defaults() {
281 let config = StalenessConfig::default();
282
283 assert_eq!(config.threshold_days, 3);
284 assert!(config.warn_enabled);
285 assert!(!config.auto_update);
286 }
287
288 #[test]
289 fn test_age_display_formatting() {
290 let recent = Utc::now() - Duration::hours(5);
292 let timestamp = recent.format("%Y-%m-%d %H:%M:%S UTC").to_string();
293 let freshness = DataFreshness::from_timestamp("HA", Some(×tamp), 3);
294 assert!(freshness.age_display.contains("h"));
295
296 let old = Utc::now() - Duration::days(2) - Duration::hours(3);
298 let timestamp = old.format("%Y-%m-%d %H:%M:%S UTC").to_string();
299 let freshness = DataFreshness::from_timestamp("HA", Some(×tamp), 3);
300 assert!(freshness.age_display.contains("d"));
301 }
302
303 #[test]
304 fn test_parse_fcc_datetime() {
305 let result = parse_fcc_datetime("Tue Jan 13 08:31:48 EST 2026");
306 assert!(result.is_some());
307 let dt = result.unwrap();
308 assert_eq!(dt.year(), 2026);
309 assert_eq!(dt.month(), 1);
310 assert_eq!(dt.day(), 13);
311 }
312
313 #[test]
314 fn test_parse_fcc_datetime_invalid() {
315 assert!(parse_fcc_datetime("invalid").is_none());
316 assert!(parse_fcc_datetime("").is_none());
317 assert!(parse_fcc_datetime("2026-01-13").is_none()); }
319
320 #[test]
321 fn test_freshness_from_fcc_format() {
322 let freshness =
325 DataFreshness::from_timestamp("HA", Some("Tue Jan 13 08:31:48 EST 2026"), 3);
326 assert!(freshness.is_stale);
327 assert!(freshness.last_updated.is_some());
328 }
329}