1#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DateGranularity {
14 Year,
16 Month,
18 Day,
20 Instant,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
26#[serde(transparent)]
27pub struct TransactionTime(pub chrono::DateTime<chrono::Utc>);
28
29impl TransactionTime {
30 pub fn now() -> Self {
32 Self(chrono::Utc::now())
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
39pub struct ValidTime {
40 pub start: Option<chrono::DateTime<chrono::Utc>>,
42 pub end: Option<chrono::DateTime<chrono::Utc>>,
44 pub valid_time_confidence: f32,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub start_granularity: Option<DateGranularity>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub end_granularity: Option<DateGranularity>,
59}
60
61pub fn date_granularity_to_str(g: DateGranularity) -> &'static str {
70 match g {
71 DateGranularity::Year => "year",
72 DateGranularity::Month => "month",
73 DateGranularity::Day => "day",
74 DateGranularity::Instant => "instant",
75 }
76}
77
78pub fn str_to_date_granularity(s: &str) -> Option<DateGranularity> {
83 match s {
84 "year" => Some(DateGranularity::Year),
85 "month" => Some(DateGranularity::Month),
86 "day" => Some(DateGranularity::Day),
87 "instant" => Some(DateGranularity::Instant),
88 _ => None,
89 }
90}
91
92pub fn parse_valid_time_date(
111 input: &str,
112) -> Option<(chrono::DateTime<chrono::Utc>, DateGranularity)> {
113 use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
114
115 let s = input.trim();
116
117 if let Ok(dt) = s.parse::<chrono::DateTime<chrono::Utc>>() {
119 return Some((dt, DateGranularity::Instant));
120 }
121
122 if s.len() == 10 && s.chars().nth(4) == Some('-') && s.chars().nth(7) == Some('-') {
124 if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
125 let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
126 return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Day));
127 }
128 }
129
130 if s.len() == 7 && s.chars().nth(4) == Some('-') {
132 let padded = format!("{s}-01");
133 if let Ok(nd) = NaiveDate::parse_from_str(&padded, "%Y-%m-%d") {
134 let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
135 return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Month));
136 }
137 }
138
139 if s.len() == 4 && s.chars().all(|c| c.is_ascii_digit()) {
141 if let Ok(year) = s.parse::<i32>() {
142 let nd = NaiveDate::from_ymd_opt(year, 1, 1)?;
143 let ndt = NaiveDateTime::new(nd, chrono::NaiveTime::from_hms_opt(0, 0, 0)?);
144 return Some((Utc.from_utc_datetime(&ndt), DateGranularity::Year));
145 }
146 }
147
148 None
149}
150
151pub fn format_valid_time_endpoint(
179 date: Option<chrono::DateTime<chrono::Utc>>,
180 granularity: Option<DateGranularity>,
181) -> Option<String> {
182 let dt = date?;
183 Some(match granularity {
184 Some(DateGranularity::Year) => format!("{}", dt.format("%Y")),
185 Some(DateGranularity::Month) => format!("{}", dt.format("%Y-%m")),
186 Some(DateGranularity::Day) | Some(DateGranularity::Instant) => {
190 format!("{}", dt.format("%Y-%m-%d"))
191 }
192 None => format!("{}", dt.format("%Y-%m-%d")),
194 })
195}
196
197impl ValidTime {
198 pub fn is_unknown(&self) -> bool {
200 self.start.is_none() && self.end.is_none()
201 }
202
203 pub fn is_temporally_incoherent(&self, tx_time: &TransactionTime) -> bool {
206 if let (Some(s), Some(e)) = (self.start, self.end) {
207 if s > e {
208 return true;
209 }
210 }
211 if let Some(s) = self.start {
212 if s > tx_time.0 {
213 return true;
214 }
215 }
216 false
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use chrono::Utc;
224
225 #[test]
226 fn valid_time_unknown_when_both_none() {
227 let vt = ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None};
228 assert!(vt.is_unknown());
229 }
230
231 #[test]
232 fn valid_time_not_unknown_when_start_set() {
233 let vt = ValidTime { start: Some(Utc::now()), end: None, valid_time_confidence: 0.8 , start_granularity: None, end_granularity: None};
234 assert!(!vt.is_unknown());
235 }
236
237 #[test]
238 fn incoherent_when_start_after_end() {
239 let now = Utc::now();
240 let tx = TransactionTime(now);
241 let vt = ValidTime {
242 start: Some(now + chrono::Duration::hours(1)),
243 end: Some(now),
244 valid_time_confidence: 1.0,
245 start_granularity: None, end_granularity: None,
246 };
247 assert!(vt.is_temporally_incoherent(&tx));
248 }
249
250 #[test]
251 fn incoherent_when_valid_start_after_tx_time() {
252 let now = Utc::now();
253 let tx = TransactionTime(now);
254 let vt = ValidTime {
255 start: Some(now + chrono::Duration::hours(1)),
256 end: None,
257 valid_time_confidence: 1.0,
258 start_granularity: None, end_granularity: None,
259 };
260 assert!(vt.is_temporally_incoherent(&tx));
261 }
262
263 #[test]
264 fn coherent_normal_interval() {
265 let now = Utc::now();
266 let tx = TransactionTime(now);
267 let vt = ValidTime {
268 start: Some(now - chrono::Duration::days(1)),
269 end: Some(now),
270 valid_time_confidence: 0.9,
271 start_granularity: None, end_granularity: None,
272 };
273 assert!(!vt.is_temporally_incoherent(&tx));
274 }
275
276 #[test]
277 fn transaction_time_ordering() {
278 let t1 = TransactionTime(Utc::now());
279 let t2 = TransactionTime(Utc::now() + chrono::Duration::seconds(1));
280 assert!(t1 < t2);
281 }
282
283 #[test]
284 fn transaction_time_serializes_as_bare_iso8601_string() {
285 use chrono::TimeZone;
286 let dt = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
288 let tt = TransactionTime(dt);
289 let json = serde_json::to_string(&tt).unwrap();
290 assert!(json.starts_with('"'), "expected a bare JSON string, got: {json}");
292 assert!(json.contains("2024-01-15"), "expected date in serialized form, got: {json}");
293 let back: TransactionTime = serde_json::from_str(&json).unwrap();
294 assert_eq!(tt, back);
295 }
296
297 #[test]
298 fn valid_time_round_trip_serde() {
299 let vt = ValidTime { start: Some(Utc::now()), end: None, valid_time_confidence: 0.7 , start_granularity: None, end_granularity: None};
300 let json = serde_json::to_string(&vt).unwrap();
301 let back: ValidTime = serde_json::from_str(&json).unwrap();
302 assert_eq!(vt.start, back.start);
303 assert_eq!(vt.end, back.end);
304 }
305
306 #[test]
310 fn valid_time_old_format_compat_no_granularity_field() {
311 let old_json = r#"{"start":null,"end":null,"valid_time_confidence":0.0}"#;
312 let vt: ValidTime = serde_json::from_str(old_json).unwrap();
313 assert_eq!(vt.start_granularity, None, "old-format ValidTime must deserialise start_granularity=None");
314 assert_eq!(vt.end_granularity, None, "old-format ValidTime must deserialise end_granularity=None");
315 }
316
317 #[test]
319 fn valid_time_with_granularity_round_trips() {
320 use chrono::TimeZone;
321 let dt = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
322 let vt = ValidTime {
323 start: Some(dt),
324 end: None,
325 valid_time_confidence: 0.9,
326 start_granularity: Some(DateGranularity::Year),
327 end_granularity: None,
328 };
329 let json = serde_json::to_string(&vt).unwrap();
330 let back: ValidTime = serde_json::from_str(&json).unwrap();
331 assert_eq!(back.start_granularity, Some(DateGranularity::Year));
332 assert_eq!(back.end_granularity, None);
333 assert_eq!(back.start, Some(dt));
334 }
335
336 #[test]
338 fn valid_time_with_both_granularities_round_trips() {
339 use chrono::TimeZone;
340 let start = Utc.with_ymd_and_hms(2020, 3, 1, 0, 0, 0).unwrap();
341 let end = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
342 let vt = ValidTime {
343 start: Some(start),
344 end: Some(end),
345 valid_time_confidence: 0.9,
346 start_granularity: Some(DateGranularity::Month),
347 end_granularity: Some(DateGranularity::Year),
348 };
349 let json = serde_json::to_string(&vt).unwrap();
350 let back: ValidTime = serde_json::from_str(&json).unwrap();
351 assert_eq!(back.start_granularity, Some(DateGranularity::Month));
352 assert_eq!(back.end_granularity, Some(DateGranularity::Year));
353 }
354
355 #[test]
357 fn parse_year_only() {
358 let (dt, gran) = parse_valid_time_date("2024").unwrap();
359 assert_eq!(gran, DateGranularity::Year);
360 assert_eq!(dt.to_rfc3339(), "2024-01-01T00:00:00+00:00");
361 }
362
363 #[test]
365 fn parse_year_month() {
366 let (dt, gran) = parse_valid_time_date("2024-05").unwrap();
367 assert_eq!(gran, DateGranularity::Month);
368 assert_eq!(dt.to_rfc3339(), "2024-05-01T00:00:00+00:00");
369 }
370
371 #[test]
373 fn parse_year_month_day() {
374 let (dt, gran) = parse_valid_time_date("2024-05-15").unwrap();
375 assert_eq!(gran, DateGranularity::Day);
376 assert_eq!(dt.to_rfc3339(), "2024-05-15T00:00:00+00:00");
377 }
378
379 #[test]
381 fn parse_full_instant() {
382 let (dt, gran) = parse_valid_time_date("2024-05-15T10:30:00Z").unwrap();
383 assert_eq!(gran, DateGranularity::Instant);
384 assert_eq!(dt.to_rfc3339(), "2024-05-15T10:30:00+00:00");
385 }
386
387 #[test]
389 fn parse_invalid_returns_none() {
390 assert!(parse_valid_time_date("not-a-date").is_none());
391 assert!(parse_valid_time_date("").is_none());
392 assert!(parse_valid_time_date("24-05").is_none());
393 }
394
395 #[test]
397 fn date_granularity_serde_snake_case() {
398 let g = DateGranularity::Year;
399 let json = serde_json::to_string(&g).unwrap();
400 assert_eq!(json, r#""year""#);
401 let back: DateGranularity = serde_json::from_str(&json).unwrap();
402 assert_eq!(back, DateGranularity::Year);
403 }
404
405 #[test]
407 fn granularity_none_not_serialised() {
408 let vt = ValidTime { start: None, end: None, valid_time_confidence: 0.0, start_granularity: None, end_granularity: None };
409 let json = serde_json::to_string(&vt).unwrap();
410 assert!(!json.contains("granularity"), "None granularity fields must not appear in serialised JSON");
411 }
412
413 #[test]
415 fn granularity_some_is_serialised() {
416 use chrono::TimeZone;
417 let vt = ValidTime {
418 start: Some(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
419 end: None,
420 valid_time_confidence: 0.9,
421 start_granularity: Some(DateGranularity::Month),
422 end_granularity: None,
423 };
424 let json = serde_json::to_string(&vt).unwrap();
425 assert!(json.contains("start_granularity"), "Some start_granularity must appear in serialised JSON");
426 assert!(json.contains("month"), "granularity value must be 'month'");
427 assert!(!json.contains("end_granularity"), "None end_granularity must not appear in serialised JSON");
428 }
429
430 #[test]
434 fn render_helper_year_granularity_no_month_day() {
435 use chrono::TimeZone;
436 let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
437 let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Year)).unwrap();
438 assert_eq!(out, "2020", "Year granularity must output only YYYY");
439 assert!(!out.contains('-'), "Year output must not contain a dash (no month/day)");
440 }
441
442 #[test]
444 fn render_helper_month_granularity_no_day() {
445 use chrono::TimeZone;
446 let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
447 let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Month)).unwrap();
448 assert_eq!(out, "2020-03", "Month granularity must output YYYY-MM");
449 let parts: Vec<&str> = out.splitn(4, '-').collect();
451 assert_eq!(parts.len(), 2, "Month output must have exactly two dash-separated parts (YYYY-MM)");
452 }
453
454 #[test]
456 fn render_helper_day_granularity_full_date() {
457 use chrono::TimeZone;
458 let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
459 let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Day)).unwrap();
460 assert_eq!(out, "2020-03-15");
461 }
462
463 #[test]
466 fn render_helper_instant_granularity_renders_at_day() {
467 use chrono::TimeZone;
468 let dt = Utc.with_ymd_and_hms(2020, 3, 15, 10, 30, 0).unwrap();
469 let out = format_valid_time_endpoint(Some(dt), Some(DateGranularity::Instant)).unwrap();
470 assert_eq!(out, "2020-03-15", "Instant renders at day precision");
471 }
472
473 #[test]
475 fn render_helper_none_granularity_falls_back_to_day() {
476 use chrono::TimeZone;
477 let dt = Utc.with_ymd_and_hms(2020, 3, 15, 0, 0, 0).unwrap();
478 let out = format_valid_time_endpoint(Some(dt), None).unwrap();
479 assert_eq!(out, "2020-03-15", "None granularity must fall back to YYYY-MM-DD");
480 }
481
482 #[test]
484 fn render_helper_none_date_returns_none() {
485 assert_eq!(format_valid_time_endpoint(None, Some(DateGranularity::Month)), None);
486 assert_eq!(format_valid_time_endpoint(None, None), None);
487 }
488}