diff --git a/CHANGELOG.md b/CHANGELOG.md index 705254a..6e55473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +0.2.17 (TBD) +============ +TODO + +Enhancements: + +* [#412](https://github.com/BurntSushi/jiff/issues/412): +Add `Display`, `FromStr`, `Serialize` and `Deserialize` trait implementations +for `jiff::civil::ISOWeekDate`. These all use the ISO 8601 week date format. + + 0.2.16 (2025-11-07) =================== This release contains a number of enhancements and bug fixes that have accrued diff --git a/src/civil/iso_week_date.rs b/src/civil/iso_week_date.rs index 7452546..2bcdd3f 100644 --- a/src/civil/iso_week_date.rs +++ b/src/civil/iso_week_date.rs @@ -1,6 +1,7 @@ use crate::{ civil::{Date, DateTime, Weekday}, error::{err, Error}, + fmt::temporal::{DEFAULT_DATETIME_PARSER, DEFAULT_DATETIME_PRINTER}, util::{ rangeint::RInto, t::{self, ISOWeek, ISOYear, C}, @@ -35,6 +36,51 @@ use crate::{ /// specifically want a week oriented calendar, it's likely that you'll never /// need to care about this type. /// +/// # Parsing and printing +/// +/// The `ISOWeekDate` type provides convenient trait implementations of +/// [`std::str::FromStr`] and [`std::fmt::Display`]. These use the format +/// specified by ISO 8601 for week dates: +/// +/// ``` +/// use jiff::civil::ISOWeekDate; +/// +/// let week_date: ISOWeekDate = "2024-W24-7".parse()?; +/// assert_eq!(week_date.to_string(), "2024-W24-7"); +/// assert_eq!(week_date.date().to_string(), "2024-06-16"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// ISO 8601 allows the `-` separator to be absent: +/// +/// ``` +/// use jiff::civil::ISOWeekDate; +/// +/// let week_date: ISOWeekDate = "2024W241".parse()?; +/// assert_eq!(week_date.to_string(), "2024-W24-1"); +/// assert_eq!(week_date.date().to_string(), "2024-06-10"); +/// +/// // But you cannot mix and match. Either `-` separates +/// // both the year and week, or neither. +/// assert!("2024W24-1".parse::().is_err()); +/// assert!("2024-W241".parse::().is_err()); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// And the `W` may also be lowercase: +/// +/// ``` +/// use jiff::civil::ISOWeekDate; +/// +/// let week_date: ISOWeekDate = "2024-w24-2".parse()?; +/// assert_eq!(week_date.to_string(), "2024-W24-2"); +/// assert_eq!(week_date.date().to_string(), "2024-06-11"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// /// # Default value /// /// For convenience, this type implements the `Default` trait. Its default @@ -747,6 +793,24 @@ impl core::fmt::Debug for ISOWeekDate { } } +impl core::fmt::Display for ISOWeekDate { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + use crate::fmt::StdFmtWrite; + + DEFAULT_DATETIME_PRINTER + .print_iso_week_date(self, StdFmtWrite(f)) + .map_err(|_| core::fmt::Error) + } +} + +impl core::str::FromStr for ISOWeekDate { + type Err = Error; + + fn from_str(string: &str) -> Result { + DEFAULT_DATETIME_PARSER.parse_iso_week_date(string) + } +} + impl Eq for ISOWeekDate {} impl PartialEq for ISOWeekDate { @@ -808,6 +872,60 @@ impl<'a> From<&'a Zoned> for ISOWeekDate { } } +#[cfg(feature = "serde")] +impl serde_core::Serialize for ISOWeekDate { + #[inline] + fn serialize( + &self, + serializer: S, + ) -> Result { + serializer.collect_str(self) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde_core::Deserialize<'de> for ISOWeekDate { + #[inline] + fn deserialize>( + deserializer: D, + ) -> Result { + use serde_core::de; + + struct ISOWeekDateVisitor; + + impl<'de> de::Visitor<'de> for ISOWeekDateVisitor { + type Value = ISOWeekDate; + + fn expecting( + &self, + f: &mut core::fmt::Formatter, + ) -> core::fmt::Result { + f.write_str("an ISO 8601 week date string") + } + + #[inline] + fn visit_bytes( + self, + value: &[u8], + ) -> Result { + DEFAULT_DATETIME_PARSER + .parse_iso_week_date(value) + .map_err(de::Error::custom) + } + + #[inline] + fn visit_str( + self, + value: &str, + ) -> Result { + self.visit_bytes(value.as_bytes()) + } + } + + deserializer.deserialize_str(ISOWeekDateVisitor) + } +} + #[cfg(test)] impl quickcheck::Arbitrary for ISOWeekDate { fn arbitrary(g: &mut quickcheck::Gen) -> ISOWeekDate { diff --git a/src/fmt/temporal/mod.rs b/src/fmt/temporal/mod.rs index 15178e5..52fd799 100644 --- a/src/fmt/temporal/mod.rs +++ b/src/fmt/temporal/mod.rs @@ -171,7 +171,7 @@ There is some more [background on Temporal's format] available. */ use crate::{ - civil, + civil::{self, ISOWeekDate}, error::Error, fmt::Write, span::Span, @@ -1110,6 +1110,22 @@ impl DateTimeParser { let pieces = parsed.to_pieces()?; Ok(pieces) } + + /// Parses an ISO 8601 week date. + /// + /// This isn't exported because it's not clear that it's worth it. + /// Moreover, this isn't part of the Temporal spec, so it's a little odd + /// to have it here. If this really needs to be exported, we probably need + /// a new module that wraps and re-uses this module's internal parser to + /// avoid too much code duplication. + pub(crate) fn parse_iso_week_date>( + &self, + input: I, + ) -> Result { + let input = input.as_ref(); + let wd = self.p.parse_iso_week_date(input)?.into_full()?; + Ok(wd) + } } /// A printer for Temporal datetimes. @@ -1964,6 +1980,25 @@ impl DateTimePrinter { ) -> Result<(), Error> { self.p.print_pieces(pieces, wtr) } + + /// Prints an ISO 8601 week date. + /// + /// This isn't exported because it's not clear that it's worth it. + /// Moreover, this isn't part of the Temporal spec, so it's a little odd + /// to have it here. But it's very convenient to have the ISO 8601 week + /// date parser in this module, and so we stick the printer here along + /// with it. + /// + /// Note that this printer will use `w` when `lowercase` is enabled. (It + /// isn't possible to enable this using the current Jiff public API. But + /// it's probably fine.) + pub(crate) fn print_iso_week_date( + &self, + iso_week_date: &ISOWeekDate, + wtr: W, + ) -> Result<(), Error> { + self.p.print_iso_week_date(iso_week_date, wtr) + } } /// A parser for Temporal durations. @@ -2455,7 +2490,7 @@ mod tests { ); insta::assert_snapshot!( DateTimeParser::new().parse_date("-000000-01-01").unwrap_err(), - @r###"failed to parse year in date "-000000-01-01": year zero must be written without a sign or a positive sign, but not a negative sign"###, + @"failed to parse year in date `-000000-01-01`: year zero must be written without a sign or a positive sign, but not a negative sign", ); } diff --git a/src/fmt/temporal/parser.rs b/src/fmt/temporal/parser.rs index 9826f5e..5444804 100644 --- a/src/fmt/temporal/parser.rs +++ b/src/fmt/temporal/parser.rs @@ -1,5 +1,5 @@ use crate::{ - civil::{Date, DateTime, Time}, + civil::{Date, DateTime, ISOWeekDate, Time, Weekday}, error::{err, Error, ErrorContext}, fmt::{ offset::{self, ParsedOffset}, @@ -559,6 +559,64 @@ impl DateTimeParser { } } + /// Parses an ISO 8601 week date. + /// + /// Note that this isn't part of the Temporal ISO 8601 spec. We put it here + /// because it shares a fair bit of code with parsing regular ISO 8601 + /// dates. + #[cfg_attr(feature = "perf-inline", inline(always))] + pub(super) fn parse_iso_week_date<'i>( + &self, + input: &'i [u8], + ) -> Result, Error> { + let original = escape::Bytes(input); + + // Parse year component. + let Parsed { value: year, input } = + self.parse_year(input).with_context(|| { + err!("failed to parse year in date `{original}`") + })?; + let extended = input.starts_with(b"-"); + + // Parse optional separator. + let Parsed { input, .. } = self + .parse_date_separator(input, extended) + .context("failed to parse separator after year")?; + + // Parse 'W' prefix before week num. + let Parsed { input, .. } = + self.parse_week_prefix(input).with_context(|| { + err!( + "failed to parse week number prefix \ + in date `{original}`" + ) + })?; + + // Parse week num component. + let Parsed { value: week, input } = + self.parse_week_num(input).with_context(|| { + err!("failed to parse week number in date `{original}`") + })?; + + // Parse optional separator. + let Parsed { input, .. } = self + .parse_date_separator(input, extended) + .context("failed to parse separator after week number")?; + + // Parse day component. + let Parsed { value: weekday, input } = + self.parse_weekday(input).with_context(|| { + err!("failed to parse weekday in date `{original}`") + })?; + + let iso_week_date = ISOWeekDate::new_ranged(year, week, weekday) + .with_context(|| { + err!("week date parsed from `{original}` is not valid") + })?; + + Ok(Parsed { value: iso_week_date, input: input }) + } + // Date ::: // DateYear - DateMonth - DateDay // DateYear DateMonth DateDay @@ -573,7 +631,7 @@ impl DateTimeParser { // Parse year component. let Parsed { value: year, input } = self.parse_year(input).with_context(|| { - err!("failed to parse year in date {original:?}") + err!("failed to parse year in date `{original}`") })?; let extended = input.starts_with(b"-"); @@ -585,7 +643,7 @@ impl DateTimeParser { // Parse month component. let Parsed { value: month, input } = self.parse_month(input).with_context(|| { - err!("failed to parse month in date {original:?}") + err!("failed to parse month in date `{original}`") })?; // Parse optional separator. @@ -596,11 +654,11 @@ impl DateTimeParser { // Parse day component. let Parsed { value: day, input } = self.parse_day(input).with_context(|| { - err!("failed to parse day in date {original:?}") + err!("failed to parse day in date `{original}`") })?; let date = Date::new_ranged(year, month, day).with_context(|| { - err!("date parsed from {original:?} is not valid") + err!("date parsed from `{original}` is not valid") })?; let value = ParsedDate { input: escape::Bytes(mkslice(input)), date }; Ok(Parsed { value, input }) @@ -623,7 +681,7 @@ impl DateTimeParser { // Parse hour component. let Parsed { value: hour, input } = self.parse_hour(input).with_context(|| { - err!("failed to parse hour in time {original:?}") + err!("failed to parse hour in time `{original}`") })?; let extended = input.starts_with(b":"); @@ -646,7 +704,7 @@ impl DateTimeParser { } let Parsed { value: minute, input } = self.parse_minute(input).with_context(|| { - err!("failed to parse minute in time {original:?}") + err!("failed to parse minute in time `{original}`") })?; // Parse optional second component. @@ -668,7 +726,7 @@ impl DateTimeParser { } let Parsed { value: second, input } = self.parse_second(input).with_context(|| { - err!("failed to parse second in time {original:?}") + err!("failed to parse second in time `{original}`") })?; // Parse an optional fractional component. @@ -676,7 +734,7 @@ impl DateTimeParser { parse_temporal_fraction(input).with_context(|| { err!( "failed to parse fractional nanoseconds \ - in time {original:?}", + in time `{original}`", ) })?; @@ -720,7 +778,7 @@ impl DateTimeParser { // Parse month component. let Parsed { value: month, mut input } = self.parse_month(input).with_context(|| { - err!("failed to parse month in month-day {original:?}") + err!("failed to parse month in month-day `{original}`") })?; // Skip over optional separator. @@ -731,7 +789,7 @@ impl DateTimeParser { // Parse day component. let Parsed { value: day, input } = self.parse_day(input).with_context(|| { - err!("failed to parse day in month-day {original:?}") + err!("failed to parse day in month-day `{original}`") })?; // Check that the month-day is valid. Since Temporal's month-day @@ -740,7 +798,7 @@ impl DateTimeParser { // user. let year = t::Year::N::<2024>(); let _ = Date::new_ranged(year, month, day).with_context(|| { - err!("month-day parsed from {original:?} is not valid") + err!("month-day parsed from `{original}` is not valid") })?; // We have a valid year-month. But we don't return it because we just @@ -763,7 +821,7 @@ impl DateTimeParser { // Parse year component. let Parsed { value: year, mut input } = self.parse_year(input).with_context(|| { - err!("failed to parse year in date {original:?}") + err!("failed to parse year in date `{original}`") })?; // Skip over optional separator. @@ -774,14 +832,14 @@ impl DateTimeParser { // Parse month component. let Parsed { value: month, input } = self.parse_month(input).with_context(|| { - err!("failed to parse month in month-day {original:?}") + err!("failed to parse month in month-day `{original}`") })?; // Check that the year-month is valid. We just use a day of 1, since // every month in every year must have a day 1. let day = t::Day::N::<1>(); let _ = Date::new_ranged(year, month, day).with_context(|| { - err!("year-month parsed from {original:?} is not valid") + err!("year-month parsed from `{original}` is not valid") })?; // We have a valid year-month. But we don't return it because we just @@ -865,7 +923,7 @@ impl DateTimeParser { })?; let month = parse::i64(month).with_context(|| { err!( - "failed to parse {month:?} as month (a two digit integer)", + "failed to parse `{month}` as month (a two digit integer)", month = escape::Bytes(month), ) })?; @@ -890,7 +948,7 @@ impl DateTimeParser { })?; let day = parse::i64(day).with_context(|| { err!( - "failed to parse {day:?} as day (a two digit integer)", + "failed to parse `{day}` as day (a two digit integer)", day = escape::Bytes(day), ) })?; @@ -947,7 +1005,7 @@ impl DateTimeParser { })?; let minute = parse::i64(minute).with_context(|| { err!( - "failed to parse {minute:?} as minute (a two digit integer)", + "failed to parse `{minute}` as minute (a two digit integer)", minute = escape::Bytes(minute), ) })?; @@ -977,7 +1035,7 @@ impl DateTimeParser { })?; let mut second = parse::i64(second).with_context(|| { err!( - "failed to parse {second:?} as second (a two digit integer)", + "failed to parse `{second}` as second (a two digit integer)", second = escape::Bytes(second), ) })?; @@ -1031,19 +1089,19 @@ impl DateTimeParser { if input.starts_with(b"-") { return Err(err!( "expected no separator after month since none was \ - found after the year, but found a '-' separator", + found after the year, but found a `-` separator", )); } return Ok(Parsed { value: (), input }); } if input.is_empty() { return Err(err!( - "expected '-' separator, but found end of input" + "expected `-` separator, but found end of input" )); } if input[0] != b'-' { return Err(err!( - "expected '-' separator, but found {found:?} instead", + "expected `-` separator, but found `{found}` instead", found = escape::Byte(input[0]), )); } @@ -1109,6 +1167,75 @@ impl DateTimeParser { input = &input[1..]; Parsed { value: Some(sign), input } } + + /// Parses the `W` that is expected to appear before the week component in + /// an ISO 8601 week date. + #[cfg_attr(feature = "perf-inline", inline(always))] + fn parse_week_prefix<'i>( + &self, + mut input: &'i [u8], + ) -> Result, Error> { + if input.is_empty() { + return Err(err!("expected `W` or `w`, but found end of input")); + } + if !matches!(input[0], b'W' | b'w') { + return Err(err!( + "expected `W` or `w`, but found `{found}` instead", + found = escape::Byte(input[0]), + )); + } + input = &input[1..]; + Ok(Parsed { value: (), input }) + } + + /// Parses the week number that follows a `W` in an ISO week date. + #[cfg_attr(feature = "perf-inline", inline(always))] + fn parse_week_num<'i>( + &self, + input: &'i [u8], + ) -> Result, Error> { + let (week_num, input) = parse::split(input, 2).ok_or_else(|| { + err!("expected two digit week number, but found end of input") + })?; + let week_num = parse::i64(week_num).with_context(|| { + err!( + "failed to parse `{week_num}` as week number, \ + expected a two digit integer", + week_num = escape::Bytes(week_num), + ) + })?; + let week_num = t::ISOWeek::try_new("week_num", week_num) + .with_context(|| { + err!("parsed week number `{week_num}` is not valid") + })?; + + Ok(Parsed { value: week_num, input }) + } + + /// Parses the weekday (1-indexed, starting with Monday) in an ISO 8601 + /// week date. + #[cfg_attr(feature = "perf-inline", inline(always))] + fn parse_weekday<'i>( + &self, + input: &'i [u8], + ) -> Result, Error> { + let (weekday, input) = parse::split(input, 1).ok_or_else(|| { + err!("expected one digit weekday, but found end of input") + })?; + let weekday = parse::i64(weekday).with_context(|| { + err!( + "failed to parse `{weekday}` as weekday (a one digit integer)", + weekday = escape::Bytes(weekday), + ) + })?; + let weekday = t::WeekdayOne::try_new("weekday", weekday) + .with_context(|| { + err!("parsed weekday `{weekday}` is not valid") + })?; + let weekday = Weekday::from_monday_one_offset_ranged(weekday); + + Ok(Parsed { value: weekday, input }) + } } /// A parser for Temporal spans. @@ -2220,7 +2347,7 @@ mod tests { // invalid time. (Because we're asking for a time here.) insta::assert_snapshot!( p(b"2099-13-01[America/New_York]"), - @r###"failed to parse minute in time "2099-13-01[America/New_York]": minute is not valid: parameter 'minute' with value 99 is not in the required range of 0..=59"###, + @"failed to parse minute in time `2099-13-01[America/New_York]`: minute is not valid: parameter 'minute' with value 99 is not in the required range of 0..=59", ); } @@ -2303,7 +2430,7 @@ mod tests { fn err_date_empty() { insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"").unwrap_err(), - @r###"failed to parse year in date "": expected four digit year (or leading sign for six digit year), but found end of input"###, + @"failed to parse year in date ``: expected four digit year (or leading sign for six digit year), but found end of input", ); } @@ -2311,40 +2438,40 @@ mod tests { fn err_date_year() { insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"123").unwrap_err(), - @r###"failed to parse year in date "123": expected four digit year (or leading sign for six digit year), but found end of input"###, + @"failed to parse year in date `123`: expected four digit year (or leading sign for six digit year), but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"123a").unwrap_err(), - @r###"failed to parse year in date "123a": failed to parse "123a" as year (a four digit integer): invalid digit, expected 0-9 but got a"###, + @r#"failed to parse year in date `123a`: failed to parse "123a" as year (a four digit integer): invalid digit, expected 0-9 but got a"#, ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"-9999").unwrap_err(), - @r###"failed to parse year in date "-9999": expected six digit year (because of a leading sign), but found end of input"###, + @"failed to parse year in date `-9999`: expected six digit year (because of a leading sign), but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"+9999").unwrap_err(), - @r###"failed to parse year in date "+9999": expected six digit year (because of a leading sign), but found end of input"###, + @"failed to parse year in date `+9999`: expected six digit year (because of a leading sign), but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"-99999").unwrap_err(), - @r###"failed to parse year in date "-99999": expected six digit year (because of a leading sign), but found end of input"###, + @"failed to parse year in date `-99999`: expected six digit year (because of a leading sign), but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"+99999").unwrap_err(), - @r###"failed to parse year in date "+99999": expected six digit year (because of a leading sign), but found end of input"###, + @"failed to parse year in date `+99999`: expected six digit year (because of a leading sign), but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"-99999a").unwrap_err(), - @r###"failed to parse year in date "-99999a": failed to parse "99999a" as year (a six digit integer): invalid digit, expected 0-9 but got a"###, + @r#"failed to parse year in date `-99999a`: failed to parse "99999a" as year (a six digit integer): invalid digit, expected 0-9 but got a"#, ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"+999999").unwrap_err(), - @r###"failed to parse year in date "+999999": year is not valid: parameter 'year' with value 999999 is not in the required range of -9999..=9999"###, + @"failed to parse year in date `+999999`: year is not valid: parameter 'year' with value 999999 is not in the required range of -9999..=9999", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"-010000").unwrap_err(), - @r###"failed to parse year in date "-010000": year is not valid: parameter 'year' with value 10000 is not in the required range of -9999..=9999"###, + @"failed to parse year in date `-010000`: year is not valid: parameter 'year' with value 10000 is not in the required range of -9999..=9999", ); } @@ -2352,19 +2479,19 @@ mod tests { fn err_date_month() { insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-").unwrap_err(), - @r###"failed to parse month in date "2024-": expected two digit month, but found end of input"###, + @"failed to parse month in date `2024-`: expected two digit month, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024").unwrap_err(), - @r###"failed to parse month in date "2024": expected two digit month, but found end of input"###, + @"failed to parse month in date `2024`: expected two digit month, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-13-01").unwrap_err(), - @r###"failed to parse month in date "2024-13-01": month is not valid: parameter 'month' with value 13 is not in the required range of 1..=12"###, + @"failed to parse month in date `2024-13-01`: month is not valid: parameter 'month' with value 13 is not in the required range of 1..=12", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"20241301").unwrap_err(), - @r###"failed to parse month in date "20241301": month is not valid: parameter 'month' with value 13 is not in the required range of 1..=12"###, + @"failed to parse month in date `20241301`: month is not valid: parameter 'month' with value 13 is not in the required range of 1..=12", ); } @@ -2372,27 +2499,27 @@ mod tests { fn err_date_day() { insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-12-").unwrap_err(), - @r###"failed to parse day in date "2024-12-": expected two digit day, but found end of input"###, + @"failed to parse day in date `2024-12-`: expected two digit day, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"202412").unwrap_err(), - @r###"failed to parse day in date "202412": expected two digit day, but found end of input"###, + @"failed to parse day in date `202412`: expected two digit day, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-12-40").unwrap_err(), - @r###"failed to parse day in date "2024-12-40": day is not valid: parameter 'day' with value 40 is not in the required range of 1..=31"###, + @"failed to parse day in date `2024-12-40`: day is not valid: parameter 'day' with value 40 is not in the required range of 1..=31", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-11-31").unwrap_err(), - @r###"date parsed from "2024-11-31" is not valid: parameter 'day' with value 31 is not in the required range of 1..=30"###, + @"date parsed from `2024-11-31` is not valid: parameter 'day' with value 31 is not in the required range of 1..=30", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-02-30").unwrap_err(), - @r###"date parsed from "2024-02-30" is not valid: parameter 'day' with value 30 is not in the required range of 1..=29"###, + @"date parsed from `2024-02-30` is not valid: parameter 'day' with value 30 is not in the required range of 1..=29", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2023-02-29").unwrap_err(), - @r###"date parsed from "2023-02-29" is not valid: parameter 'day' with value 29 is not in the required range of 1..=28"###, + @"date parsed from `2023-02-29` is not valid: parameter 'day' with value 29 is not in the required range of 1..=28", ); } @@ -2400,11 +2527,11 @@ mod tests { fn err_date_separator() { insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"2024-1231").unwrap_err(), - @r###"failed to parse separator after month: expected '-' separator, but found "3" instead"###, + @"failed to parse separator after month: expected `-` separator, but found `3` instead", ); insta::assert_snapshot!( DateTimeParser::new().parse_date_spec(b"202412-31").unwrap_err(), - @"failed to parse separator after month: expected no separator after month since none was found after the year, but found a '-' separator", + @"failed to parse separator after month: expected no separator after month since none was found after the year, but found a `-` separator", ); } @@ -2533,7 +2660,7 @@ mod tests { fn err_time_empty() { insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"").unwrap_err(), - @r###"failed to parse hour in time "": expected two digit hour, but found end of input"###, + @"failed to parse hour in time ``: expected two digit hour, but found end of input", ); } @@ -2541,15 +2668,15 @@ mod tests { fn err_time_hour() { insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"a").unwrap_err(), - @r###"failed to parse hour in time "a": expected two digit hour, but found end of input"###, + @"failed to parse hour in time `a`: expected two digit hour, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"1a").unwrap_err(), - @r###"failed to parse hour in time "1a": failed to parse "1a" as hour (a two digit integer): invalid digit, expected 0-9 but got a"###, + @r#"failed to parse hour in time `1a`: failed to parse "1a" as hour (a two digit integer): invalid digit, expected 0-9 but got a"#, ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"24").unwrap_err(), - @r###"failed to parse hour in time "24": hour is not valid: parameter 'hour' with value 24 is not in the required range of 0..=23"###, + @"failed to parse hour in time `24`: hour is not valid: parameter 'hour' with value 24 is not in the required range of 0..=23", ); } @@ -2557,19 +2684,19 @@ mod tests { fn err_time_minute() { insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:").unwrap_err(), - @r###"failed to parse minute in time "01:": expected two digit minute, but found end of input"###, + @"failed to parse minute in time `01:`: expected two digit minute, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:a").unwrap_err(), - @r###"failed to parse minute in time "01:a": expected two digit minute, but found end of input"###, + @"failed to parse minute in time `01:a`: expected two digit minute, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:1a").unwrap_err(), - @r###"failed to parse minute in time "01:1a": failed to parse "1a" as minute (a two digit integer): invalid digit, expected 0-9 but got a"###, + @"failed to parse minute in time `01:1a`: failed to parse `1a` as minute (a two digit integer): invalid digit, expected 0-9 but got a", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:60").unwrap_err(), - @r###"failed to parse minute in time "01:60": minute is not valid: parameter 'minute' with value 60 is not in the required range of 0..=59"###, + @"failed to parse minute in time `01:60`: minute is not valid: parameter 'minute' with value 60 is not in the required range of 0..=59", ); } @@ -2577,19 +2704,19 @@ mod tests { fn err_time_second() { insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:").unwrap_err(), - @r###"failed to parse second in time "01:02:": expected two digit second, but found end of input"###, + @"failed to parse second in time `01:02:`: expected two digit second, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:a").unwrap_err(), - @r###"failed to parse second in time "01:02:a": expected two digit second, but found end of input"###, + @"failed to parse second in time `01:02:a`: expected two digit second, but found end of input", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:1a").unwrap_err(), - @r###"failed to parse second in time "01:02:1a": failed to parse "1a" as second (a two digit integer): invalid digit, expected 0-9 but got a"###, + @"failed to parse second in time `01:02:1a`: failed to parse `1a` as second (a two digit integer): invalid digit, expected 0-9 but got a", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:61").unwrap_err(), - @r###"failed to parse second in time "01:02:61": second is not valid: parameter 'second' with value 61 is not in the required range of 0..=59"###, + @"failed to parse second in time `01:02:61`: second is not valid: parameter 'second' with value 61 is not in the required range of 0..=59", ); } @@ -2597,11 +2724,262 @@ mod tests { fn err_time_fractional() { insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:03.").unwrap_err(), - @r###"failed to parse fractional nanoseconds in time "01:02:03.": found decimal after seconds component, but did not find any decimal digits after decimal"###, + @"failed to parse fractional nanoseconds in time `01:02:03.`: found decimal after seconds component, but did not find any decimal digits after decimal", ); insta::assert_snapshot!( DateTimeParser::new().parse_time_spec(b"01:02:03.a").unwrap_err(), - @r###"failed to parse fractional nanoseconds in time "01:02:03.a": found decimal after seconds component, but did not find any decimal digits after decimal"###, + @"failed to parse fractional nanoseconds in time `01:02:03.a`: found decimal after seconds component, but did not find any decimal digits after decimal", + ); + } + + #[test] + fn ok_iso_week_date_parse_basic() { + fn p(input: &str) -> Parsed<'_, ISOWeekDate> { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap() + } + + insta::assert_debug_snapshot!( p("2024-W01-5"), @r#" + Parsed { + value: ISOWeekDate { + year: 2024, + week: 1, + weekday: Friday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2024-W52-7"), @r#" + Parsed { + value: ISOWeekDate { + year: 2024, + week: 52, + weekday: Sunday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2004-W53-6"), @r#" + Parsed { + value: ISOWeekDate { + year: 2004, + week: 53, + weekday: Saturday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2009-W01-1"), @r#" + Parsed { + value: ISOWeekDate { + year: 2009, + week: 1, + weekday: Monday, + }, + input: "", + } + "#); + + insta::assert_debug_snapshot!( p("2024W015"), @r#" + Parsed { + value: ISOWeekDate { + year: 2024, + week: 1, + weekday: Friday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2024W527"), @r#" + Parsed { + value: ISOWeekDate { + year: 2024, + week: 52, + weekday: Sunday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2004W536"), @r#" + Parsed { + value: ISOWeekDate { + year: 2004, + week: 53, + weekday: Saturday, + }, + input: "", + } + "#); + insta::assert_debug_snapshot!( p("2009W011"), @r#" + Parsed { + value: ISOWeekDate { + year: 2009, + week: 1, + weekday: Monday, + }, + input: "", + } + "#); + + // Lowercase should be okay. This matches how + // we support `T` or `t`. + insta::assert_debug_snapshot!( p("2009w011"), @r#" + Parsed { + value: ISOWeekDate { + year: 2009, + week: 1, + weekday: Monday, + }, + input: "", + } + "#); + } + + #[test] + fn err_iso_week_date_year() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + + insta::assert_snapshot!( + p("123"), + @"failed to parse year in date `123`: expected four digit year (or leading sign for six digit year), but found end of input", + ); + insta::assert_snapshot!( + p("123a"), + @r#"failed to parse year in date `123a`: failed to parse "123a" as year (a four digit integer): invalid digit, expected 0-9 but got a"#, + ); + + insta::assert_snapshot!( + p("-9999"), + @"failed to parse year in date `-9999`: expected six digit year (because of a leading sign), but found end of input", + ); + insta::assert_snapshot!( + p("+9999"), + @"failed to parse year in date `+9999`: expected six digit year (because of a leading sign), but found end of input", + ); + insta::assert_snapshot!( + p("-99999"), + @"failed to parse year in date `-99999`: expected six digit year (because of a leading sign), but found end of input", + ); + insta::assert_snapshot!( + p("+99999"), + @"failed to parse year in date `+99999`: expected six digit year (because of a leading sign), but found end of input", + ); + insta::assert_snapshot!( + p("-99999a"), + @r#"failed to parse year in date `-99999a`: failed to parse "99999a" as year (a six digit integer): invalid digit, expected 0-9 but got a"#, + ); + insta::assert_snapshot!( + p("+999999"), + @"failed to parse year in date `+999999`: year is not valid: parameter 'year' with value 999999 is not in the required range of -9999..=9999", + ); + insta::assert_snapshot!( + p("-010000"), + @"failed to parse year in date `-010000`: year is not valid: parameter 'year' with value 10000 is not in the required range of -9999..=9999", + ); + } + + #[test] + fn err_iso_week_date_week_prefix() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + + insta::assert_snapshot!( + p("2024-"), + @"failed to parse week number prefix in date `2024-`: expected `W` or `w`, but found end of input", + ); + insta::assert_snapshot!( + p("2024"), + @"failed to parse week number prefix in date `2024`: expected `W` or `w`, but found end of input", + ); + } + + #[test] + fn err_iso_week_date_week_number() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + + insta::assert_snapshot!( + p("2024-W"), + @"failed to parse week number in date `2024-W`: expected two digit week number, but found end of input", + ); + insta::assert_snapshot!( + p("2024-W1"), + @"failed to parse week number in date `2024-W1`: expected two digit week number, but found end of input", + ); + insta::assert_snapshot!( + p("2024-W53-1"), + @"week date parsed from `2024-W53-1` is not valid: ISO week number `53` is invalid for year `2024`", + ); + insta::assert_snapshot!( + p("2030W531"), + @"week date parsed from `2030W531` is not valid: ISO week number `53` is invalid for year `2030`", + ); + } + + #[test] + fn err_iso_week_date_parse_incomplete() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + + insta::assert_snapshot!( + p("2024-W53-1"), + @"week date parsed from `2024-W53-1` is not valid: ISO week number `53` is invalid for year `2024`", + ); + insta::assert_snapshot!( + p("2025-W53-1"), + @"week date parsed from `2025-W53-1` is not valid: ISO week number `53` is invalid for year `2025`", + ); + } + + #[test] + fn err_iso_week_date_date_day() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + insta::assert_snapshot!( + p("2024-W12-"), + @"failed to parse weekday in date `2024-W12-`: expected one digit weekday, but found end of input", + ); + insta::assert_snapshot!( + p("2024W12"), + @"failed to parse weekday in date `2024W12`: expected one digit weekday, but found end of input", + ); + insta::assert_snapshot!( + p("2024-W11-8"), + @"failed to parse weekday in date `2024-W11-8`: parsed weekday `8` is not valid: parameter 'weekday' with value 8 is not in the required range of 1..=7", + ); + } + + #[test] + fn err_iso_week_date_date_separator() { + let p = |input: &str| { + DateTimeParser::new() + .parse_iso_week_date(input.as_bytes()) + .unwrap_err() + }; + insta::assert_snapshot!( + p("2024-W521"), + @"failed to parse separator after week number: expected `-` separator, but found `1` instead", + ); + insta::assert_snapshot!( + p("2024W01-5"), + @"failed to parse separator after week number: expected no separator after month since none was found after the year, but found a `-` separator", ); } } diff --git a/src/fmt/temporal/printer.rs b/src/fmt/temporal/printer.rs index 8e80a7d..c65ad80 100644 --- a/src/fmt/temporal/printer.rs +++ b/src/fmt/temporal/printer.rs @@ -1,5 +1,5 @@ use crate::{ - civil::{Date, DateTime, Time}, + civil::{Date, DateTime, ISOWeekDate, Time}, error::{err, Error}, fmt::{ temporal::{Pieces, PiecesOffset, TimeZoneAnnotationKind}, @@ -255,6 +255,34 @@ impl DateTimePrinter { Ok(()) } + pub(super) fn print_iso_week_date( + &self, + iso_week_date: &ISOWeekDate, + mut wtr: W, + ) -> Result<(), Error> { + static FMT_YEAR_POSITIVE: DecimalFormatter = + DecimalFormatter::new().padding(4); + static FMT_YEAR_NEGATIVE: DecimalFormatter = + DecimalFormatter::new().padding(6); + static FMT_TWO: DecimalFormatter = DecimalFormatter::new().padding(2); + static FMT_ONE: DecimalFormatter = DecimalFormatter::new().padding(1); + + if iso_week_date.year() >= 0 { + wtr.write_int(&FMT_YEAR_POSITIVE, iso_week_date.year())?; + } else { + wtr.write_int(&FMT_YEAR_NEGATIVE, iso_week_date.year())?; + } + wtr.write_str("-")?; + wtr.write_char(if self.lowercase { 'w' } else { 'W' })?; + wtr.write_int(&FMT_TWO, iso_week_date.week())?; + wtr.write_str("-")?; + wtr.write_int( + &FMT_ONE, + iso_week_date.weekday().to_monday_one_offset(), + )?; + Ok(()) + } + /// Formats the given "pieces" offset into the writer given. fn print_pieces_offset( &self, @@ -620,7 +648,10 @@ impl SpanPrinter { mod tests { use alloc::string::String; - use crate::{civil::date, span::ToSpan}; + use crate::{ + civil::{date, Weekday}, + span::ToSpan, + }; use super::*; @@ -922,4 +953,22 @@ mod tests { @"PT5124095576030431H15.999999999S", ); } + + #[test] + fn print_iso_week_date() { + let p = |d: ISOWeekDate| -> String { + let mut buf = String::new(); + DateTimePrinter::new().print_iso_week_date(&d, &mut buf).unwrap(); + buf + }; + + insta::assert_snapshot!( + p(ISOWeekDate::new(2024, 52, Weekday::Monday).unwrap()), + @"2024-W52-1", + ); + insta::assert_snapshot!( + p(ISOWeekDate::new(2004, 1, Weekday::Sunday).unwrap()), + @"2004-W01-7", + ); + } }