use std::borrow::Borrow;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::syntax::SyntaxError;
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Datetime(String);
impl Datetime {
pub fn parse(raw: &str) -> Result<Self, SyntaxError> {
Datetime::try_from(raw)
}
pub fn parse_lenient(raw: &str) -> Result<Self, SyntaxError> {
if let Ok(dt) = Datetime::try_from(raw) {
return Ok(dt);
}
let mut fixed = raw.to_owned();
if fixed.ends_with("-00:00") {
let end = fixed.len() - 6;
fixed.truncate(end);
fixed.push_str("+00:00");
}
if fixed.ends_with("-0000") || fixed.ends_with("+0000") {
let end = fixed.len() - 5;
fixed.truncate(end);
fixed.push_str("+00:00");
}
if !has_timezone(&fixed) {
fixed.push('Z');
}
Datetime::try_from(fixed.as_str())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Datetime {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for Datetime {
fn borrow(&self) -> &str {
&self.0
}
}
impl TryFrom<&str> for Datetime {
type Error = SyntaxError;
fn try_from(raw: &str) -> Result<Self, Self::Error> {
let err = |msg: &str| SyntaxError::InvalidDatetime(format!("{raw:?}: {msg}"));
if raw.is_empty() {
return Err(err("empty"));
}
if raw.len() > 64 {
return Err(err("too long"));
}
validate_datetime_syntax(raw).map_err(|_| err("invalid datetime syntax"))?;
Ok(Datetime(raw.to_owned()))
}
}
impl FromStr for Datetime {
type Err = SyntaxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Datetime::try_from(s)
}
}
impl Serialize for Datetime {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Datetime {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Datetime::try_from(s.as_str()).map_err(serde::de::Error::custom)
}
}
fn validate_datetime_syntax(raw: &str) -> Result<(), ()> {
let n = raw.len();
if n < 20 {
return Err(());
}
let b = raw.as_bytes();
if !b[0].is_ascii_digit()
|| !b[1].is_ascii_digit()
|| !b[2].is_ascii_digit()
|| !b[3].is_ascii_digit()
{
return Err(());
}
if b[4] != b'-' {
return Err(());
}
if !b[5].is_ascii_digit() || !b[6].is_ascii_digit() {
return Err(());
}
if b[7] != b'-' {
return Err(());
}
if !b[8].is_ascii_digit() || !b[9].is_ascii_digit() {
return Err(());
}
if b[10] != b'T' {
return Err(());
}
if !b[11].is_ascii_digit() || !b[12].is_ascii_digit() {
return Err(());
}
if b[13] != b':' {
return Err(());
}
if !b[14].is_ascii_digit() || !b[15].is_ascii_digit() {
return Err(());
}
if b[16] != b':' {
return Err(());
}
if !b[17].is_ascii_digit() || !b[18].is_ascii_digit() {
return Err(());
}
let month = (b[5] - b'0') * 10 + (b[6] - b'0');
if !(1..=12).contains(&month) {
return Err(());
}
let day = (b[8] - b'0') * 10 + (b[9] - b'0');
if !(1..=31).contains(&day) {
return Err(());
}
let hour = (b[11] - b'0') * 10 + (b[12] - b'0');
if hour > 23 {
return Err(());
}
let minute = (b[14] - b'0') * 10 + (b[15] - b'0');
if minute > 59 {
return Err(());
}
let second = (b[17] - b'0') * 10 + (b[18] - b'0');
if second > 60 {
return Err(());
}
let mut i = 19usize;
if i < n && b[i] == b'.' {
i += 1;
let frac_start = i;
while i < n && b[i].is_ascii_digit() {
i += 1;
}
let frac_len = i - frac_start;
if frac_len == 0 || frac_len > 20 {
return Err(());
}
}
if i >= n {
return Err(());
}
match b[i] {
b'Z' => {
i += 1;
}
b'+' | b'-' => {
if &raw[i..] == "-00:00" {
return Err(());
}
i += 1;
if i + 5 > n {
return Err(());
}
if !b[i].is_ascii_digit() || !b[i + 1].is_ascii_digit() {
return Err(());
}
if b[i + 2] != b':' {
return Err(());
}
if !b[i + 3].is_ascii_digit() || !b[i + 4].is_ascii_digit() {
return Err(());
}
i += 5;
}
_ => return Err(()),
}
if i != n {
return Err(());
}
Ok(())
}
fn has_timezone(s: &str) -> bool {
if s.is_empty() {
return false;
}
let b = s.as_bytes();
if b.last().copied() == Some(b'Z') {
return true;
}
if s.len() >= 6 {
let c = b[s.len() - 6];
if c == b'+' || c == b'-' {
return true;
}
}
false
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable
)]
mod tests {
use super::*;
#[test]
fn datetime_valid_z() {
Datetime::try_from("2024-01-01T00:00:00Z").unwrap();
}
#[test]
fn datetime_valid_offset() {
Datetime::try_from("2024-01-01T00:00:00+00:00").unwrap();
}
#[test]
fn datetime_reject_negative_zero() {
assert!(Datetime::try_from("2024-01-01T00:00:00-00:00").is_err());
}
#[test]
fn datetime_reject_lowercase_z() {
assert!(Datetime::try_from("2024-01-01T00:00:00z").is_err());
}
#[test]
fn datetime_reject_no_timezone() {
assert!(Datetime::try_from("2024-01-01T00:00:00").is_err());
}
#[test]
fn datetime_with_fractional() {
Datetime::try_from("2024-01-01T00:00:00.123Z").unwrap();
}
#[test]
fn datetime_serde_roundtrip() {
let dt = Datetime::try_from("2024-01-01T12:30:00Z").unwrap();
let json = serde_json::to_string(&dt).unwrap();
let parsed: Datetime = serde_json::from_str(&json).unwrap();
assert_eq!(dt, parsed);
}
#[test]
fn datetime_lenient_negative_zero() {
assert!(Datetime::try_from("2024-01-01T00:00:00-00:00").is_err());
Datetime::parse_lenient("2024-01-01T00:00:00-00:00").unwrap();
}
#[test]
fn datetime_lenient_no_timezone() {
assert!(Datetime::try_from("2024-01-01T00:00:00").is_err());
Datetime::parse_lenient("2024-01-01T00:00:00").unwrap();
}
#[test]
fn datetime_reject_space_separator() {
assert!(Datetime::try_from("2024-01-01 00:00:00Z").is_err());
}
#[test]
fn datetime_reject_empty() {
assert!(Datetime::try_from("").is_err());
}
}