diff --git a/src/chrono.rs b/src/chrono.rs index 29c3c256..6dfbb18b 100644 --- a/src/chrono.rs +++ b/src/chrono.rs @@ -87,8 +87,8 @@ impl TryFrom for chrono::DateTime { .. } = iso.time; - let offset_minutes = tz_offset_hours * 3600 + tz_offset_minutes; - let offset = chrono::FixedOffset::east_opt(offset_minutes).ok_or(())?; + let offset_seconds = tz_offset_hours * 3600 + tz_offset_minutes * 60; + let offset = chrono::FixedOffset::east_opt(offset_seconds).ok_or(())?; let naive_time = chrono::NaiveTime::try_from(iso.time)?; let naive_date_time = chrono::NaiveDate::try_from(iso.date)?.and_time(naive_time); @@ -128,7 +128,7 @@ mod test_datetime { assert_eq!(datetime.hour(), 23); assert_eq!(datetime.minute(), 40); assert_eq!(datetime.second(), 00); - assert_eq!(datetime.offset().fix().local_minus_utc(), 3623); + assert_eq!(datetime.offset().fix().local_minus_utc(), 4980); } #[test] @@ -170,7 +170,7 @@ mod test_datetime { assert_eq!(datetime.hour(), 23); assert_eq!(datetime.minute(), 40); assert_eq!(datetime.second(), 00); - assert_eq!(datetime.offset().fix().local_minus_utc(), 3623); + assert_eq!(datetime.offset().fix().local_minus_utc(), 4980); } #[test] @@ -183,4 +183,46 @@ mod test_datetime { assert_eq!(naive.second(), 15); assert_eq!(naive.nanosecond(), 123_000_000); } + + #[test] + fn datetime_from_iso_ordinal() { + let iso = crate::datetime("2023-039T23:40:00+01:23").unwrap(); + let datetime = chrono::DateTime::try_from(iso).unwrap(); + + assert_eq!(datetime.year(), 2023); + assert_eq!(datetime.month(), 2); + assert_eq!(datetime.day(), 8); + assert_eq!(datetime.hour(), 23); + assert_eq!(datetime.minute(), 40); + assert_eq!(datetime.second(), 00); + assert_eq!(datetime.offset().fix().local_minus_utc(), 4980); + } + + #[test] + fn datetime_from_iso_ymd_negative_year() { + let iso = crate::datetime("-2023-039T23:40:00+01:23").unwrap(); + let datetime = chrono::DateTime::try_from(iso).unwrap(); + + assert_eq!(datetime.year(), -2023); + assert_eq!(datetime.month(), 2); + assert_eq!(datetime.day(), 8); + assert_eq!(datetime.hour(), 23); + assert_eq!(datetime.minute(), 40); + assert_eq!(datetime.second(), 00); + assert_eq!(datetime.offset().fix().local_minus_utc(), 4980); + } + + #[test] + fn datetime_from_iso_ymd_negative_offset() { + let iso = crate::datetime("2023-02-08T23:40:00-05:30").unwrap(); + let datetime = chrono::DateTime::try_from(iso).unwrap(); + + assert_eq!(datetime.year(), 2023); + assert_eq!(datetime.month(), 2); + assert_eq!(datetime.day(), 8); + assert_eq!(datetime.hour(), 23); + assert_eq!(datetime.minute(), 40); + assert_eq!(datetime.second(), 00); + assert_eq!(datetime.offset().fix().local_minus_utc(), -19800); + } } diff --git a/src/display.rs b/src/display.rs index 0bbc407c..79e43892 100644 --- a/src/display.rs +++ b/src/display.rs @@ -2,15 +2,30 @@ use core::fmt::{self, Display}; use super::{Date, DateTime, Duration, Time}; +fn write_year(f: &mut fmt::Formatter, year: i32) -> fmt::Result { + if year < 0 { + let year = -year; + write!(f, "-{year:04}") + } else { + write!(f, "{year:04}") + } +} + impl Display for Date { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { - // like `2015-11-02` - Date::YMD { year, month, day } => write!(f, "{:04}-{:02}-{:02}", year, month, day), - // like `2015-W45-01` - Date::Week { year, ww, d } => write!(f, "{:04}-W{:02}-{:01}", year, ww, d), - // like `2015-306` - Date::Ordinal { year, ddd } => write!(f, "{:04}-{:03}", year, ddd), + Date::YMD { year, month, day } => { + write_year(f, year)?; + write!(f, "-{month:02}-{day:02}") + } + Date::Week { year, ww, d } => { + write_year(f, year)?; + write!(f, "-W{ww:02}-{d:01}") + } + Date::Ordinal { year, ddd } => { + write_year(f, year)?; + write!(f, "-{ddd:03}") + } } } } @@ -18,15 +33,23 @@ impl Display for Date { impl Display for Time { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { // like `16:43:16.123+00:00` + let (sign, tz_offset_hours, tz_offset_minutes) = + if self.tz_offset_hours < 0 || self.tz_offset_minutes < 0 { + ('-', -self.tz_offset_hours, -self.tz_offset_minutes) + } else { + ('+', self.tz_offset_hours, self.tz_offset_minutes) + }; + write!( f, - "{:02}:{:02}:{:02}.{:03}+{:02}:{:02}", + "{:02}:{:02}:{:02}.{:03}{}{:02}:{:02}", self.hour, self.minute, self.second, self.millisecond, - self.tz_offset_hours, - self.tz_offset_minutes + sign, + tz_offset_hours, + tz_offset_minutes ) } } @@ -97,14 +120,14 @@ mod tests { use super::*; - fn test_duration_reparse(duration: Duration) { + fn assert_duration_reparse(duration: Duration) { let serialized = format!("{}", duration); let reparsed = parse_duration(serialized.as_bytes()).unwrap().1; assert_eq!(duration, reparsed); } #[test] - fn display_duration_0() { + fn duration_0() { let duration = Duration::YMDHMS { year: 2021, month: 11, @@ -114,11 +137,11 @@ mod tests { second: 59, millisecond: 0, }; - test_duration_reparse(duration); + assert_duration_reparse(duration); } #[test] - fn display_duration_1() { + fn duration_1() { let duration = Duration::YMDHMS { year: 2021, month: 11, @@ -128,17 +151,17 @@ mod tests { second: 59, millisecond: 123, }; - test_duration_reparse(duration); + assert_duration_reparse(duration); } #[test] - fn display_duration_2() { + fn duration_2() { let duration = Duration::Weeks(50); - test_duration_reparse(duration); + assert_duration_reparse(duration); } #[test] - fn display_duration_small_milliseconds() { + fn duration_small_milliseconds() { for millisecond in [1, 10, 100] { let duration = Duration::YMDHMS { year: 0, @@ -149,16 +172,99 @@ mod tests { second: 0, millisecond, }; - test_duration_reparse(duration); + assert_duration_reparse(duration); } } #[test] - fn display_time_small_milliseconds() { + fn time_small_milliseconds() { let time = crate::time("16:43:16.001").unwrap(); assert_eq!(format!("{}", time), "16:43:16.001+00:00"); let time = crate::time("16:43:16.010").unwrap(); assert_eq!(format!("{}", time), "16:43:16.010+00:00"); } + + fn assert_date_reparse(date: Date) { + let serialized = format!("{}", date); + let reparsed = crate::parsers::parse_date(serialized.as_bytes()).unwrap().1; + assert_eq!(date, reparsed); + } + + #[test] + fn date_ymd() { + assert_date_reparse(Date::YMD { + year: 2015, + month: 6, + day: 26, + }); + } + + #[test] + fn date_week() { + assert_date_reparse(Date::Week { + year: 2015, + ww: 45, + d: 1, + }); + } + + #[test] + fn date_ordinal() { + assert_date_reparse(Date::Ordinal { + year: 2015, + ddd: 306, + }); + } + + #[test] + fn date_negative_year() { + assert_date_reparse(Date::YMD { + year: -333, + month: 7, + day: 11, + }); + } + + fn assert_datetime_reparse(datetime: DateTime) { + let serialized = format!("{}", datetime); + let reparsed = crate::parsers::parse_datetime(serialized.as_bytes()) + .unwrap() + .1; + assert_eq!(datetime, reparsed); + } + + #[test] + fn datetime_with_positive_offset() { + assert_datetime_reparse(DateTime { + date: Date::YMD { + year: 2015, + month: 6, + day: 26, + }, + time: Time { + hour: 16, + minute: 43, + second: 16, + millisecond: 123, + tz_offset_hours: 5, + tz_offset_minutes: 30, + }, + }); + } + + #[test] + fn time_with_negative_offset() { + let time = Time { + hour: 16, + minute: 43, + second: 16, + millisecond: 0, + tz_offset_hours: -5, + tz_offset_minutes: 0, + }; + let serialized = format!("{}", time); + let reparsed = crate::parsers::parse_time(serialized.as_bytes()).unwrap().1; + assert_eq!(time, reparsed, "serialized as {:?}", serialized); + } } diff --git a/src/parsers.rs b/src/parsers.rs index acd439d7..dcf7fc66 100644 --- a/src/parsers.rs +++ b/src/parsers.rs @@ -101,7 +101,7 @@ fn date_day(i: &[u8]) -> IResult<&[u8], u32> { // WW fn date_week(i: &[u8]) -> IResult<&[u8], u32> { - n_digit_in_range(i, 2, 1..=52) + n_digit_in_range(i, 2, 1..=53) } fn date_week_day(i: &[u8]) -> IResult<&[u8], u32> { diff --git a/src/serde.rs b/src/serde.rs index 7f8ac95f..8e0fccd6 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -64,6 +64,66 @@ mod date { assert_eq!(deserialized_date, date); } + + #[test] + fn serialize_week_date() { + let date_json = r#""2023-W06-2""#; + let date = crate::date("2023-W06-2").unwrap(); + + let serialized_date = serde_json::to_string(&date).unwrap(); + + assert_eq!(serialized_date, date_json); + } + + #[test] + fn deserialize_week_date() { + let date_json = r#""2023-W06-2""#; + let date = crate::date("2023-W06-2").unwrap(); + + let deserialized_date = serde_json::from_str::(date_json).unwrap(); + + assert_eq!(deserialized_date, date); + } + + #[test] + fn serialize_ordinal_date() { + let date_json = r#""2023-039""#; + let date = crate::date("2023-039").unwrap(); + + let serialized_date = serde_json::to_string(&date).unwrap(); + + assert_eq!(serialized_date, date_json); + } + + #[test] + fn deserialize_ordinal_date() { + let date_json = r#""2023-039""#; + let date = crate::date("2023-039").unwrap(); + + let deserialized_date = serde_json::from_str::(date_json).unwrap(); + + assert_eq!(deserialized_date, date); + } + + #[test] + fn serialize_negative_year() { + let date_json = r#""-0333-07-11""#; + let date = crate::date("-0333-07-11").unwrap(); + + let serialized_date = serde_json::to_string(&date).unwrap(); + + assert_eq!(serialized_date, date_json); + } + + #[test] + fn deserialize_negative_year() { + let date_json = r#""-0333-07-11""#; + let date = crate::date("-0333-07-11").unwrap(); + + let deserialized_date = serde_json::from_str::(date_json).unwrap(); + + assert_eq!(deserialized_date, date); + } } mod time { @@ -256,4 +316,24 @@ mod duration { assert_eq!(deserialized_duration, duration); } + + #[test] + fn serialize_weeks() { + let duration_json = r#""P26W""#; + let duration = crate::duration("P26W").unwrap(); + + let serialized_duration = serde_json::to_string(&duration).unwrap(); + + assert_eq!(serialized_duration, duration_json); + } + + #[test] + fn deserialize_weeks() { + let duration_json = r#""P26W""#; + let duration = crate::duration("P26W").unwrap(); + + let deserialized_duration = serde_json::from_str::(duration_json).unwrap(); + + assert_eq!(deserialized_duration, duration); + } } diff --git a/tests/lib.rs b/tests/lib.rs index 469fc841..7381b9d4 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -1225,3 +1225,219 @@ fn test_duration_weeks() { assert_eq!(Duration::Weeks(52), dur); assert_eq!(StdDuration::from(dur), StdDuration::new(31449600, 0)); } + +#[rustfmt::skip] +mod test_negative_and_expanded_years { + use super::*; + + #[test] + fn week_date_with_negative_year() { + assert_eq!( + Ok(Date::Week { year: -333, ww: 7, d: 2}), + date("-0333-W07-2") + ); + } + + #[test] + fn ordinal_date_with_negative_year() { + assert_eq!( + Ok(Date::Ordinal { year: -333, ddd: 192 }), + date("-0333-192") + ); + } + + #[test] + fn ymd_date_with_negative_year_basic_format() { + assert_eq!( + Ok(Date::YMD { year: -333, month: 7, day: 11 }), + date("-03330711") + ); + } + + #[test] + fn datetime_with_negative_year() { + assert_eq!( + Ok(DateTime { + date: Date::YMD { year: -333, month: 7, day: 11 }, + time: Time { hour: 10, minute: 0, second: 0, millisecond: 0, tz_offset_hours: 0, tz_offset_minutes: 0 }, + }), + datetime("-0333-07-11T10:00:00") + ); + } + + #[test] + fn ymd_date_with_explicit_positive_sign() { + assert_eq!( + Ok(Date::YMD { year: 2015, month: 6, day: 26 }), + date("+2015-06-26") + ); + } + + #[test] + fn year_zero_is_accepted() { + assert_eq!( + Ok(Date::YMD { year: 0, month: 1, day: 1 }), + date("0000-01-01") + ); + } +} + +#[rustfmt::skip] +mod test_iso_week_date_boundaries { + use super::*; + + #[test] + fn first_week_of_year() { + assert_eq!( + Ok(Date::Week { year: 2015, ww: 1, d: 1 }), + date("2015-W01-1") + ); + } + + #[test] + fn week_53_of_a_53_week_year() { + assert_eq!( + Ok(Date::Week { year: 2020, ww: 53, d: 1 }), + date("2020-W53-1") + ); + } +} + +#[rustfmt::skip] +mod test_leap_second { + use super::*; + + #[test] + fn time_with_leap_second() { + assert_eq!( + Ok(Time { hour: 23, minute: 59, second: 60, millisecond: 0, tz_offset_hours: 0, tz_offset_minutes: 0 }), + time("23:59:60") + ); + } + + #[test] + fn datetime_with_leap_second() { + assert_eq!( + Ok(DateTime { + date: Date::YMD { year: 2016, month: 12, day: 31 }, + time: Time { hour: 23, minute: 59, second: 60, millisecond: 0, tz_offset_hours: 0, tz_offset_minutes: 0 }, + }), + datetime("2016-12-31T23:59:60Z") + ); + } +} + +#[rustfmt::skip] +mod test_end_of_day_midnight { + use super::*; + + // "24:00:00" is the standard's representation of end-of-day midnight. + #[test] + fn time_24_00_00() { + assert_eq!( + Ok(Time { hour: 24, minute: 0, second: 0, millisecond: 0, tz_offset_hours: 0, tz_offset_minutes: 0 }), + time("24:00:00") + ); + } + + #[test] + fn datetime_24_00_00() { + assert_eq!( + Ok(DateTime { + date: Date::YMD { year: 2015, month: 6, day: 26 }, + time: Time { hour: 24, minute: 0, second: 0, millisecond: 0, tz_offset_hours: 0, tz_offset_minutes: 0 }, + }), + datetime("2015-06-26T24:00:00") + ); + } +} + +#[rustfmt::skip] +mod test_timezone_offset_variants { + use super::*; + + #[test] + fn hour_only_positive_offset() { + assert_eq!( + Ok(Time { hour: 16, minute: 43, second: 16, millisecond: 0, tz_offset_hours: 5, tz_offset_minutes: 0 }), + time("16:43:16+05") + ); + } + + #[test] + fn hour_only_negative_offset() { + assert_eq!( + Ok(Time { hour: 16, minute: 43, second: 16, millisecond: 0, tz_offset_hours: -5, tz_offset_minutes: 0 }), + time("16:43:16-05") + ); + } + + #[test] + fn basic_format_with_hour_only_offset() { + assert_eq!( + Ok(Time { hour: 16, minute: 43, second: 16, millisecond: 0, tz_offset_hours: 5, tz_offset_minutes: 0 }), + time("164316+05") + ); + } + + // Chatham Islands (+13:45) exercises a real-world, non-zero-minute offset. + #[test] + fn quarter_hour_offset() { + assert_eq!( + Ok(Time { hour: 16, minute: 43, second: 16, millisecond: 0, tz_offset_hours: 13, tz_offset_minutes: 45 }), + time("16:43:16+13:45") + ); + } +} + +#[rustfmt::skip] +mod test_date_basic_format_via_public_api { + use super::*; + + #[test] + fn ymd_basic_format() { + assert_eq!( + Ok(Date::YMD { year: 2015, month: 6, day: 26 }), + date("20150626") + ); + } + + #[test] + fn ordinal_basic_format() { + assert_eq!( + Ok(Date::Ordinal { year: 2015, ddd: 56 }), + date("2015056") + ); + } +} + +#[rustfmt::skip] +mod test_real_calendar_leap_day { + use super::*; + + // 2016 is an actual leap year, unlike the non-leap-year example used + // elsewhere in this suite to demonstrate that calendar validity isn't checked. + #[test] + fn february_29_in_a_real_leap_year() { + assert_eq!( + Ok(Date::YMD { year: 2016, month: 2, day: 29 }), + date("2016-02-29") + ); + } +} + +#[rustfmt::skip] +mod test_duration_datetime_format_with_timezone { + use super::*; + + // The = "P" form embeds a full time, which may carry + // a timezone offset; the offset itself has no representation in `Duration` + // and is simply dropped. + #[test] + fn offset_is_accepted_and_ignored() { + assert_eq!( + Ok(Duration::YMDHMS { year: 2015, month: 11, day: 3, hour: 21, minute: 56, second: 0, millisecond: 0 }), + duration("P2015-11-03T21:56:00+02:00") + ); + } +}