use chrono::{DateTime, NaiveDate, SecondsFormat, Utc};
pub fn parse(input: &str) -> Result<DateTime<Utc>, String> {
let s = input.trim();
if let Ok(day) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let midnight = day.and_hms_opt(0, 0, 0).expect("midnight exists");
return Ok(midnight.and_utc());
}
DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&Utc))
.map_err(|_| format!("`{s}` is not a date; use YYYY-MM-DD or an ISO-8601 timestamp"))
}
pub fn format(d: DateTime<Utc>) -> String {
d.to_rfc3339_opts(SecondsFormat::Secs, true)
}
pub fn present(raw: Option<&str>) -> Option<String> {
let s = raw?.trim();
if s.is_empty() || s.starts_with("0001-") {
None
} else {
Some(s.to_string())
}
}
pub fn parsed(raw: Option<&str>) -> Option<DateTime<Utc>> {
present(raw).and_then(|s| {
DateTime::parse_from_rfc3339(&s)
.ok()
.map(|d| d.with_timezone(&Utc))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn day_is_midnight_utc() {
assert_eq!(format(parse("2026-11-05").unwrap()), "2026-11-05T00:00:00Z");
}
#[test]
fn rfc3339_is_normalised_to_utc() {
assert_eq!(
format(parse("2026-11-05T10:30:00+01:00").unwrap()),
"2026-11-05T09:30:00Z"
);
}
#[test]
fn garbage_is_refused() {
assert!(parse("next tuesday").unwrap_err().contains("YYYY-MM-DD"));
}
#[test]
fn zero_time_means_unset() {
assert_eq!(present(Some("0001-01-01T00:00:00Z")), None);
assert_eq!(present(None), None);
assert_eq!(present(Some("")), None);
assert_eq!(
present(Some("2026-11-05T00:00:00Z")).as_deref(),
Some("2026-11-05T00:00:00Z")
);
}
}