use crate::error::DateTimeSyntaxError;
#[cfg(not(feature = "chrono"))]
use crate::utils::parse_date_time_bytes;
#[cfg(not(feature = "chrono"))]
use std::fmt::Display;
#[cfg(feature = "chrono")]
#[macro_export]
macro_rules! date_time {
($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal) => {{
const D: chrono::NaiveDate = date_time!(@INTERNAL @DATE $Y-$M-$D);
const T: chrono::NaiveTime = date_time!(@INTERNAL @TIME $h:$m:$s);
D.and_time(T).and_utc().fixed_offset()
}};
($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal $x:literal:$y:literal) => {{
const D: chrono::NaiveDate = date_time!(@INTERNAL @DATE $Y-$M-$D);
const T: chrono::NaiveTime = date_time!(@INTERNAL @TIME $h:$m:$s);
const TZ: chrono::FixedOffset = date_time!(@INTERNAL @TIMEZONE $x:$y);
D.and_time(T).and_local_timezone(TZ).earliest().unwrap()
}};
(@INTERNAL @DATE $Y:literal-$M:literal-$D:literal) => {{
const D: Option<chrono::NaiveDate> = chrono::NaiveDate::from_ymd_opt($Y, $M, $D);
const _: () = assert!(D.is_some(), "Invalid date");
D.unwrap()
}};
(@INTERNAL @TIME $h:literal:$m:literal:$s:literal) => {{
const _: () = assert!($s >= 0.0, "Seconds must be positive");
const S: u32 = $s as u32;
const MS: u32 = (($s * 1000.0 as f64).round() % 1000.0) as u32;
const T: Option<chrono::NaiveTime> = chrono::NaiveTime::from_hms_milli_opt($h, $m, S, MS);
const _: () = assert!(T.is_some(), "Invalid time");
T.unwrap()
}};
(@INTERNAL @TIMEZONE $x:literal:$y:literal) => {{
const _: () = assert!($y >= 0, "Minutes must be positive");
const TZ_H: i32 = ($x as i32).abs() as i32;
const TZ_M: i32 = $y as i32;
const MULTIPLIER: i32 = if $x == TZ_H { 1 } else { -1 };
const TZ: Option<chrono::FixedOffset> =
chrono::FixedOffset::east_opt(MULTIPLIER * ((TZ_H * 3600) + (TZ_M * 60)));
const _: () = assert!(TZ.is_some(), "Invalid timezone offset");
TZ.unwrap()
}};
}
#[cfg(not(feature = "chrono"))]
#[macro_export]
macro_rules! date_time {
($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal) => {
date_time!($Y-$M-$D T $h:$m:$s 0:0)
};
($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal $x:literal:$y:literal) => {{
const _: () = assert!($Y <= 9999, "Year must be at most 4 digits");
const _: () = assert!($M > 0, "Month must be greater than 0");
const _: () = assert!($M <= 12, "Month must be less than or equal to 12");
const _: () = assert!($D > 0, "Day must be greater than 0");
const _: () = assert!($D <= 31, "Day must be less than or equal to 31");
const _: () = assert!($h < 24, "Hour must be less than 24");
const _: () = assert!($m < 60, "Minute must be less than 60");
const _: () = assert!($s >= 0.0, "Seconds must be positive");
const _: () = assert!($s < 60.0, "Seconds must be less than 60.0");
const _: () = assert!($x > -24, "Hour offset must be greater than -24");
const _: () = assert!($x < 24, "Hour offset must be less than 24");
const _: () = assert!($y < 60, "Minute offset must be less than 60");
$crate::date::DateTime {
date_fullyear: $Y,
date_month: $M,
date_mday: $D,
time_hour: $h,
time_minute: $m,
time_second: $s,
timezone_offset: $crate::date::DateTimeTimezoneOffset {
time_hour: $x,
time_minute: $y,
},
}
}};
}
#[cfg(not(feature = "chrono"))]
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct DateTime {
pub date_fullyear: u32,
pub date_month: u8,
pub date_mday: u8,
pub time_hour: u8,
pub time_minute: u8,
pub time_second: f64,
pub timezone_offset: DateTimeTimezoneOffset,
}
#[cfg(not(feature = "chrono"))]
impl Display for DateTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:04}-{:02}-{:02}T{:02}:{:02}:{:06.3}{}",
self.date_fullyear,
self.date_month,
self.date_mday,
self.time_hour,
self.time_minute,
self.time_second,
self.timezone_offset
)
}
}
#[cfg(not(feature = "chrono"))]
impl From<DateTime> for String {
fn from(value: DateTime) -> Self {
format!("{value}")
}
}
#[cfg(not(feature = "chrono"))]
impl Default for DateTime {
fn default() -> Self {
Self {
date_fullyear: 1970,
date_month: 1,
date_mday: 1,
time_hour: 0,
time_minute: 0,
time_second: 0.0,
timezone_offset: Default::default(),
}
}
}
#[cfg(not(feature = "chrono"))]
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub struct DateTimeTimezoneOffset {
pub time_hour: i8,
pub time_minute: u8,
}
#[cfg(not(feature = "chrono"))]
impl Display for DateTimeTimezoneOffset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.time_hour == 0 && self.time_minute == 0 {
write!(f, "Z")
} else {
write!(f, "{:+03}:{:02}", self.time_hour, self.time_minute)
}
}
}
#[cfg(not(feature = "chrono"))]
impl From<DateTimeTimezoneOffset> for String {
fn from(value: DateTimeTimezoneOffset) -> Self {
format!("{value}")
}
}
#[cfg(feature = "chrono")]
pub fn parse(input: &str) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeSyntaxError> {
chrono::DateTime::parse_from_rfc3339(input).map_err(DateTimeSyntaxError::from)
}
#[cfg(not(feature = "chrono"))]
pub fn parse(input: &str) -> Result<DateTime, DateTimeSyntaxError> {
parse_bytes(input.as_bytes())
}
#[cfg(feature = "chrono")]
pub fn parse_bytes(
input: &[u8],
) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeSyntaxError> {
let input_str = str::from_utf8(input)?;
parse(input_str)
}
#[cfg(not(feature = "chrono"))]
pub fn parse_bytes(input: &[u8]) -> Result<DateTime, DateTimeSyntaxError> {
Ok(parse_date_time_bytes(input)?.parsed)
}
#[cfg(feature = "chrono")]
pub fn string_from(date_time: &chrono::DateTime<chrono::FixedOffset>) -> String {
let dt = date_time.naive_local();
let date = dt.date();
let time = dt.time();
let offset = date_time.offset();
if offset.local_minus_utc() == 0 {
format!("{date}T{time}Z")
} else {
format!("{date}T{time}{offset}")
}
}
#[cfg(not(feature = "chrono"))]
pub fn string_from(date_time: &DateTime) -> String {
format!("{date_time}")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn no_timezone() {
assert_eq!(
date_time!(2025-06-04 T 13:50:42.148),
parse("2025-06-04T13:50:42.148Z").unwrap()
);
}
#[test]
fn plus_timezone() {
assert_eq!(
date_time!(2025-06-04 T 13:50:42.148 03:00),
parse("2025-06-04T13:50:42.148+03:00").unwrap()
);
}
#[test]
fn negative_timezone() {
assert_eq!(
date_time!(2025-06-04 T 13:50:42.148 -01:30),
parse("2025-06-04T13:50:42.148-01:30").unwrap()
);
}
#[test]
fn no_fractional_seconds() {
assert_eq!(
date_time!(2025-06-04 T 13:50:42.0),
parse("2025-06-04T13:50:42Z").unwrap()
);
}
#[test]
fn string_from_single_digit_dates_should_be_valid() {
assert_eq!(
String::from("2025-06-04T13:50:42.123Z"),
string_from(&date_time!(2025-06-04 T 13:50:42.123))
)
}
#[ignore = "change to chrono breaks test but maybe the expectation is wrong anyway"]
#[test]
fn string_from_no_fractional_seconds_should_still_be_3_decimals_precise() {
assert_eq!(
String::from("2025-06-04T13:50:42.000Z"),
string_from(&date_time!(2025-06-04 T 13:50:42.0))
)
}
#[test]
fn string_from_single_digit_times_should_be_valid() {
assert_eq!(
String::from("2025-12-25T04:00:02.001Z"),
string_from(&date_time!(2025-12-25 T 04:00:02.001))
)
}
#[test]
fn string_from_negative_time_offset_should_be_valid() {
assert_eq!(
String::from("2025-06-04T13:50:42.123-05:00"),
string_from(&date_time!(2025-06-04 T 13:50:42.123 -05:00))
)
}
#[test]
fn string_from_positive_offset_should_be_valid() {
assert_eq!(
String::from("2025-06-04T13:50:42.100+01:00"),
string_from(&date_time!(2025-06-04 T 13:50:42.100 01:00))
)
}
#[test]
fn string_from_positive_offset_non_zero_minutes_should_be_valid() {
assert_eq!(
String::from("2025-06-04T13:50:42.010+06:30"),
string_from(&date_time!(2025-06-04 T 13:50:42.010 06:30))
)
}
#[cfg(not(feature = "chrono"))]
#[test]
fn date_time_macro_should_work_with_no_offset() {
assert_eq!(
date_time!(2025-06-22 T 22:13:42.000),
DateTime {
date_fullyear: 2025,
date_month: 6,
date_mday: 22,
time_hour: 22,
time_minute: 13,
time_second: 42.0,
timezone_offset: DateTimeTimezoneOffset {
time_hour: 0,
time_minute: 0
}
}
);
}
#[cfg(not(feature = "chrono"))]
#[test]
fn date_time_macro_should_work_with_positive_offset() {
assert_eq!(
date_time!(2025-06-22 T 22:13:42.000 01:00),
DateTime {
date_fullyear: 2025,
date_month: 6,
date_mday: 22,
time_hour: 22,
time_minute: 13,
time_second: 42.0,
timezone_offset: DateTimeTimezoneOffset {
time_hour: 1,
time_minute: 0
}
}
);
}
#[cfg(not(feature = "chrono"))]
#[test]
fn date_time_macro_should_work_with_negative_offset() {
assert_eq!(
date_time!(2025-06-22 T 22:13:42.000 -01:30),
DateTime {
date_fullyear: 2025,
date_month: 6,
date_mday: 22,
time_hour: 22,
time_minute: 13,
time_second: 42.0,
timezone_offset: DateTimeTimezoneOffset {
time_hour: -1,
time_minute: 30
}
}
);
}
}