use alloc::string::{String, ToString};
use core::cmp;
use core::fmt;
use core::str::FromStr;
use chrono::DurationRound;
use serde::Serializer;
use serde::{Deserialize, Deserializer, Serialize, de::Error};
use smol_str::{SmolStr, ToSmolStr};
use super::Lazy;
use crate::{CowStr, IntoStatic};
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
use regex::Regex;
#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
use regex_automata::meta::Regex;
#[cfg(target_arch = "wasm32")]
use regex_lite::Regex;
pub static ISO8601_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+[0-9]{2}|\-[0-9][1-9]):[0-9]{2})$").unwrap()
});
#[derive(Clone, Debug, Eq, Hash)]
pub struct Datetime {
serialized: CowStr<'static>,
dt: chrono::DateTime<chrono::FixedOffset>,
}
impl PartialEq for Datetime {
fn eq(&self, other: &Self) -> bool {
self.dt == other.dt
}
}
impl Ord for Datetime {
fn cmp(&self, other: &Self) -> cmp::Ordering {
self.dt.cmp(&other.dt)
}
}
impl PartialOrd for Datetime {
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Datetime {
pub fn now() -> Self {
Self::new(chrono::Utc::now().fixed_offset())
}
pub fn new(dt: chrono::DateTime<chrono::FixedOffset>) -> Self {
let dt = dt
.duration_round(chrono::Duration::microseconds(1))
.expect("delta does not exceed limits");
let serialized = CowStr::Owned(
dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
.to_smolstr(),
);
Self { serialized, dt }
}
pub fn raw_str(s: impl AsRef<str>) -> Self {
let s = s.as_ref();
if ISO8601_REGEX.is_match(s) {
let dt = chrono::DateTime::parse_from_rfc3339(s).expect("valid ISO8601 time string");
Self {
serialized: CowStr::Borrowed(s).into_static(),
dt,
}
} else {
panic!("atproto datetime should be valid ISO8601")
}
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
self.serialized.as_ref()
}
#[inline]
#[must_use]
pub fn timestamp(&self) -> i64 {
self.dt.timestamp()
}
#[inline]
#[must_use]
pub fn timestamp_millis(&self) -> i64 {
self.dt.timestamp_millis()
}
#[inline]
#[must_use]
pub fn timestamp_micros(&self) -> i64 {
self.dt.timestamp_micros()
}
}
impl FromStr for Datetime {
type Err = chrono::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if ISO8601_REGEX.is_match(s) {
let dt = chrono::DateTime::parse_from_rfc3339(s)?;
Ok(Self {
serialized: CowStr::Owned(s.to_smolstr()),
dt,
})
} else {
Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
}
}
}
impl<'de> Deserialize<'de> for Datetime {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value: String = Deserialize::deserialize(deserializer)?;
Self::from_str(&value).map_err(D::Error::custom)
}
}
impl Serialize for Datetime {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.serialized)
}
}
impl AsRef<chrono::DateTime<chrono::FixedOffset>> for Datetime {
fn as_ref(&self) -> &chrono::DateTime<chrono::FixedOffset> {
&self.dt
}
}
impl TryFrom<String> for Datetime {
type Error = chrono::ParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if ISO8601_REGEX.is_match(&value) {
let dt = chrono::DateTime::parse_from_rfc3339(&value)?;
Ok(Self {
serialized: CowStr::Owned(value.to_smolstr()),
dt,
})
} else {
Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
}
}
}
impl TryFrom<CowStr<'_>> for Datetime {
type Error = chrono::ParseError;
fn try_from(value: CowStr<'_>) -> Result<Self, Self::Error> {
if ISO8601_REGEX.is_match(&value) {
let dt = chrono::DateTime::parse_from_rfc3339(&value)?;
Ok(Self {
serialized: value.into_static(),
dt,
})
} else {
Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
}
}
}
impl From<chrono::DateTime<chrono::FixedOffset>> for Datetime {
fn from(dt: chrono::DateTime<chrono::FixedOffset>) -> Self {
Self::new(dt)
}
}
impl From<Datetime> for String {
fn from(value: Datetime) -> Self {
value.serialized.to_string()
}
}
impl From<Datetime> for SmolStr {
fn from(value: Datetime) -> Self {
match value.serialized {
CowStr::Borrowed(s) => SmolStr::new(s),
CowStr::Owned(s) => s,
}
}
}
impl From<Datetime> for CowStr<'static> {
fn from(value: Datetime) -> Self {
value.serialized
}
}
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl IntoStatic for Datetime {
type Output = Datetime;
fn into_static(self) -> Self::Output {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_datetimes() {
assert!(Datetime::from_str("2023-01-15T12:30:45.123456Z").is_ok());
assert!(Datetime::from_str("2023-01-15T12:30:45Z").is_ok());
assert!(Datetime::from_str("2023-01-15T12:30:45+00:00").is_ok());
assert!(Datetime::from_str("2023-01-15T12:30:45-05:00").is_ok());
}
#[test]
fn microsecond_precision() {
let dt = Datetime::from_str("2023-01-15T12:30:45.123456Z").unwrap();
assert!(dt.as_str().contains(".123456"));
}
#[test]
fn requires_timezone() {
assert!(Datetime::from_str("2023-01-15T12:30:45").is_err());
}
#[test]
fn round_trip() {
let original = "2023-01-15T12:30:45.123456Z";
let dt = Datetime::from_str(original).unwrap();
assert_eq!(dt.as_str(), original);
}
}