use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
const DATETIME_FORMATS: &[&str] = &[
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M:%S",
"%Y.%m.%d %H:%M:%S",
"%d-%b-%Y %H:%M:%S",
"%d.%m.%Y %H:%M:%S",
"%d/%m/%Y %H:%M:%S",
"%b %d %H:%M:%S %Y",
"%a %b %d %H:%M:%S %Y",
"%Y%m%d%H%M%S",
];
const DATE_FORMATS: &[&str] = &[
"%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%d-%b-%Y", "%d %b %Y", "%b %d %Y", "%d-%B-%Y", "%Y%m%d",
];
const NOISE: &[&str] = &[
" utc", " gmt", " (utc)", " (gmt)", " z", " est", " edt", " cst", " cet", " cest", " jst",
" clst", " clt", " utc+0", " +0000", " -0000",
];
pub fn parse_datetime(value: &str) -> Option<DateTime<Utc>> {
let cleaned = clean(value);
if cleaned.is_empty() {
return None;
}
if let Ok(parsed) = DateTime::parse_from_rfc3339(&cleaned) {
return Some(parsed.with_timezone(&Utc));
}
if let Ok(parsed) = DateTime::parse_from_rfc2822(&cleaned) {
return Some(parsed.with_timezone(&Utc));
}
for format in [
"%Y-%m-%d %H:%M:%S%:z",
"%Y-%m-%dT%H:%M:%S%:z",
"%Y-%m-%d %H:%M:%S %z",
] {
if let Ok(parsed) = DateTime::parse_from_str(&cleaned, format) {
return Some(parsed.with_timezone(&Utc));
}
}
for format in DATETIME_FORMATS {
if let Ok(naive) = NaiveDateTime::parse_from_str(&cleaned, format) {
return Some(Utc.from_utc_datetime(&naive));
}
}
for format in DATE_FORMATS {
if let Ok(date) = NaiveDate::parse_from_str(&cleaned, format) {
return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
}
}
None
}
fn clean(value: &str) -> String {
let mut text = value.trim().to_string();
if let Some(index) = text.find(" (") {
let tail = text[index..].to_ascii_lowercase();
if !tail.starts_with(" (utc)") && !tail.starts_with(" (gmt)") {
text.truncate(index);
}
}
if let Some(dot) = text.find('.') {
let after = &text[dot + 1..];
let digits = after.chars().take_while(char::is_ascii_digit).count();
if digits > 0 && text[..dot].contains(':') {
let end = dot + 1 + digits;
text.replace_range(dot..end, "");
}
}
let lowered = text.to_ascii_lowercase();
for suffix in NOISE {
if lowered.ends_with(suffix) {
text.truncate(text.len() - suffix.len());
break;
}
}
text.trim().trim_end_matches(',').trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ymd(value: &str) -> Option<String> {
parse_datetime(value).map(|parsed| parsed.format("%Y-%m-%d").to_string())
}
fn full(value: &str) -> Option<String> {
parse_datetime(value).map(|parsed| parsed.format("%Y-%m-%d %H:%M:%S").to_string())
}
#[test]
fn reads_the_rfc3339_form_gtlds_use() {
assert_eq!(full("2027-03-14T09:30:00Z").unwrap(), "2027-03-14 09:30:00");
assert_eq!(
full("2027-03-14T09:30:00.123Z").unwrap(),
"2027-03-14 09:30:00"
);
}
#[test]
fn honours_a_stated_offset() {
assert_eq!(
full("2027-03-14T09:30:00+02:00").unwrap(),
"2027-03-14 07:30:00"
);
assert_eq!(
full("2027-03-14 09:30:00+0200").unwrap(),
"2027-03-14 07:30:00"
);
}
#[test]
fn reads_the_cctld_dialects() {
let cases = [
("14-Mar-2027", "2027-03-14"),
("14 Mar 2027", "2027-03-14"),
("2027.03.14", "2027-03-14"),
("2027/03/14", "2027-03-14"),
("20270314", "2027-03-14"),
("2027-03-14 09:30:00", "2027-03-14"),
("14.03.2027 09:30:00", "2027-03-14"),
("2027-03-14T09:30:00", "2027-03-14"),
];
for (value, expected) in cases {
assert_eq!(ymd(value).as_deref(), Some(expected), "for {value:?}");
}
}
#[test]
fn a_bare_date_becomes_midnight() {
assert_eq!(full("2027-03-14").unwrap(), "2027-03-14 00:00:00");
}
#[test]
fn strips_the_zone_words_registries_append() {
for value in [
"2027-03-14 09:30:00 UTC",
"2027-03-14 09:30:00 GMT",
"2027-03-14 09:30:00 (UTC)",
] {
assert_eq!(
full(value).as_deref(),
Some("2027-03-14 09:30:00"),
"for {value:?}"
);
}
}
#[test]
fn strips_trailing_prose() {
assert_eq!(
ymd("2027-03-14 (registry expiry date)").as_deref(),
Some("2027-03-14")
);
}
#[test]
fn refuses_ambiguous_numeric_dates() {
assert!(parse_datetime("03/14/2027").is_none());
assert!(parse_datetime("14/03/2027").is_none());
assert!(parse_datetime("03-14-2027").is_none());
}
#[test]
fn refuses_what_it_cannot_read() {
for value in ["", " ", "not a date", "n/a", "0000-00-00", "unknown"] {
assert!(parse_datetime(value).is_none(), "accepted {value:?}");
}
}
#[test]
fn a_dot_separator_is_not_a_fraction() {
assert_eq!(ymd("2027.03.14").as_deref(), Some("2027-03-14"));
assert_eq!(ymd("2027.03.14 09:30:00").as_deref(), Some("2027-03-14"));
}
}