use chrono::{DateTime, FixedOffset, SecondsFormat, Utc};
use regex::Regex;
use std::fmt;
use std::str::FromStr;
const MAX_DATETIME_LENGTH: usize = 64;
static DATETIME_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(
r"^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](\.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$",
)
.unwrap()
});
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Datetime(String);
#[derive(Debug, Clone, thiserror::Error)]
#[error("Invalid datetime: {reason}")]
pub struct InvalidDatetimeError {
pub reason: String,
}
impl Datetime {
pub fn new(s: &str) -> Result<Self, InvalidDatetimeError> {
ensure_valid_datetime(s)?;
Ok(Self(s.to_string()))
}
#[must_use]
pub fn is_valid(s: &str) -> bool {
ensure_valid_datetime(s).is_ok()
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
#[must_use]
pub fn now() -> Self {
let s = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
Self(s)
}
#[must_use]
pub fn from_utc(dt: DateTime<Utc>) -> Self {
Self(dt.to_rfc3339_opts(SecondsFormat::Millis, true))
}
}
#[must_use]
pub fn current_datetime_string() -> String {
Datetime::now().into_inner()
}
fn ensure_valid_datetime(s: &str) -> Result<(), InvalidDatetimeError> {
let err = |reason: &str| InvalidDatetimeError {
reason: reason.to_string(),
};
if s.len() > MAX_DATETIME_LENGTH {
return Err(err(&format!(
"Datetime too long ({} chars, max {})",
s.len(),
MAX_DATETIME_LENGTH
)));
}
if !DATETIME_REGEX.is_match(s) {
return Err(err("Datetime does not match RFC 3339 format"));
}
if s.ends_with("-00:00") {
return Err(err("Datetime cannot use -00:00 offset; use Z for UTC"));
}
if s.starts_with("000") {
return Err(err("Datetime year cannot start with 000"));
}
DateTime::parse_from_rfc3339(s).map_err(|e| err(&format!("Invalid datetime value: {e}")))?;
Ok(())
}
pub fn normalize_datetime(s: &str) -> Result<String, InvalidDatetimeError> {
ensure_valid_datetime(s)?;
let parsed: DateTime<FixedOffset> =
DateTime::parse_from_rfc3339(s).map_err(|e| InvalidDatetimeError {
reason: format!("internal: RFC 3339 reparse failed after validation: {e}"),
})?;
let utc: DateTime<Utc> = parsed.with_timezone(&Utc);
Ok(utc.to_rfc3339_opts(SecondsFormat::Millis, true))
}
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Datetime {
type Err = InvalidDatetimeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl AsRef<str> for Datetime {
fn as_ref(&self) -> &str {
&self.0
}
}
impl serde::Serialize for Datetime {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Datetime {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_datetimes() {
let cases = [
"2023-11-15T12:30:00Z",
"2023-11-15T12:30:00.123Z",
"2023-11-15T12:30:00+05:30",
"2023-11-15T12:30:00-08:00",
"2023-11-15T12:30:00.1Z",
"2023-11-15T12:30:00.12345678901234567890Z",
];
for dt in &cases {
assert!(Datetime::new(dt).is_ok(), "should be valid: {dt}");
}
}
#[test]
fn invalid_datetimes() {
assert!(Datetime::new("").is_err(), "empty");
assert!(Datetime::new("2023-11-15").is_err(), "date only");
assert!(Datetime::new("2023-11-15T12:30:00").is_err(), "no timezone");
assert!(
Datetime::new("2023-11-15T12:30:00-00:00").is_err(),
"-00:00 not allowed"
);
assert!(
Datetime::new("0001-01-01T00:00:00Z").is_err(),
"year starts with 000"
);
}
#[test]
fn normalize() {
let result = normalize_datetime("2023-11-15T12:30:00Z").unwrap();
assert_eq!(result, "2023-11-15T12:30:00.000Z");
let result = normalize_datetime("2023-11-15T12:30:00.1Z").unwrap();
assert_eq!(result, "2023-11-15T12:30:00.100Z");
let result = normalize_datetime("2023-11-15T12:30:00.123456Z").unwrap();
assert_eq!(result, "2023-11-15T12:30:00.123Z");
}
#[test]
fn normalize_handles_month_and_year_rollover() {
assert_eq!(
normalize_datetime("2023-02-01T00:30:00+02:00").unwrap(),
"2023-01-31T22:30:00.000Z",
);
assert_eq!(
normalize_datetime("2023-02-28T23:30:00-02:00").unwrap(),
"2023-03-01T01:30:00.000Z",
);
assert_eq!(
normalize_datetime("2024-02-29T12:00:00Z").unwrap(),
"2024-02-29T12:00:00.000Z",
);
assert_eq!(
normalize_datetime("2024-01-01T01:00:00+02:00").unwrap(),
"2023-12-31T23:00:00.000Z",
);
assert_eq!(
normalize_datetime("2024-02-29T23:00:00-02:00").unwrap(),
"2024-03-01T01:00:00.000Z",
);
}
#[test]
fn rejects_semantically_invalid_datetimes() {
let bad = [
"1985-00-12T23:20:50.123Z", "1985-13-12T23:20:50.123Z", "1985-04-00T23:20:50.123Z", "1985-04-31T23:20:50.123Z", "2023-02-29T12:00:00Z", "1985-04-12T25:20:50.123Z", "1985-04-12T23:99:50.123Z", "1985-04-12T23:20:61.123Z", ];
for s in bad {
assert!(
Datetime::new(s).is_err(),
"should reject semantically-invalid datetime {s:?}"
);
}
}
#[test]
fn leap_second_is_accepted_or_rejected_consistently() {
let _ = Datetime::new("1985-04-12T23:20:60Z");
}
}