diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c503a4..d9553c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,51 @@ # CHANGELOG -0.1.19 (TBD) -============ -TODO +0.1.21 (2025-01-04) +=================== +This release includes a new API for setting the unit designator label in a +friendly formatted duration for zero-length durations. + +Enhancements: + +* [#192](https://github.com/BurntSushi/jiff/issues/192): +Add option to the friendly printer for setting the unit when writing a +zero-length duration. + + +0.1.20 (2025-01-03) +=================== +This release inclues a new type, `Pieces`, in the `jiff::fmt::temporal` +sub-module. This exposes the individual components of a parsed Temporal +ISO 8601 datetime string. It allows users of Jiff to circumvent the checks +in the higher level parsing routines that prevent you from shooting yourself +in the foot. + +For example, parsing into a `Zoned` will return an error for raw RFC 3339 +timestamps like `2025-01-03T22:03-05` because there is no time zone annotation. +Without a time zone, Jiff cannot do time zone aware arithmetic and rounding. +Instead, such a datetime can only be parsed into a `Timestamp`. This lower +level `Pieces` API now permits users of Jiff to parse this string into its +component parts and assemble it into a `Zoned` if they so choose. + +Enhancements: + +* [#188](https://github.com/BurntSushi/jiff/issues/188): +Add `fmt::temporal::Pieces` for granular datetime parsing and formatting. + + +0.1.19 (2025-01-02) +=================== +This releases includes a UTF-8 related bug fix and a few enhancements. + +Firstly, a `Span`'s default `Display` implementation now writes uppercase +unit designator labels. That means you'll get `P1Y2M3DT4H5M6S` instead +of `P1y2m3dT4h5m6s` by default. You can restore previous behavior via +`jiff::fmt::temporal::SpanPrinter::lowercase`. This change was made to improve +interoperability. + +Secondly, `SignedDuration` now supports rounding via `SignedDuration::round`. +Note that it only supports rounding time units (hours or smaller). In order to +round with calendar units, you'll still need to use a `Span`. Enhancements: @@ -10,6 +53,9 @@ Enhancements: Document value ranges for methods like `year`, `day`, `hour` and so on. * [#187](https://github.com/BurntSushi/jiff/issues/187): Add a rounding API (for time units only) on `SignedDuration`. +* [#190](https://github.com/BurntSushi/jiff/issues/190): +`Span` and `SignedDuration` now use uppercase unit designator labels in their +default ISO 8601 `Display` implementation. Bug fixes: diff --git a/COMPARE.md b/COMPARE.md index 6ebf29c3..40543858 100644 --- a/COMPARE.md +++ b/COMPARE.md @@ -328,7 +328,7 @@ use jiff::{Span, ToSpan}; fn main() -> anyhow::Result<()> { let span = 5.years().months(2).days(1).hours(20); let json = serde_json::to_string_pretty(&span)?; - assert_eq!(json, "\"P5y2m1dT20h\""); + assert_eq!(json, "\"P5Y2M1DT20H\""); let got: Span = serde_json::from_str(&json)?; assert_eq!(got, span); diff --git a/Cargo.toml b/Cargo.toml index a380736c..055427d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jiff" -version = "0.1.18" #:version +version = "0.1.21" #:version authors = ["Andrew Gallant "] license = "Unlicense OR MIT" repository = "https://github.com/BurntSushi/jiff" diff --git a/src/civil/date.rs b/src/civil/date.rs index 5374edf2..165fb8e3 100644 --- a/src/civil/date.rs +++ b/src/civil/date.rs @@ -1635,11 +1635,11 @@ impl Date { /// /// // The default limits durations to using "days" as the biggest unit. /// let span = d1.until(d2)?; - /// assert_eq!(span.to_string(), "P8456d"); + /// assert_eq!(span.to_string(), "P8456D"); /// /// // But we can ask for units all the way up to years. /// let span = d1.until((Unit::Year, d2))?; - /// assert_eq!(span.to_string(), "P23y1m24d"); + /// assert_eq!(span.to_string(), "P23Y1M24D"); /// /// # Ok::<(), Box>(()) /// ``` diff --git a/src/civil/datetime.rs b/src/civil/datetime.rs index 046e0811..8e736a19 100644 --- a/src/civil/datetime.rs +++ b/src/civil/datetime.rs @@ -1775,11 +1775,11 @@ impl DateTime { /// /// // The default limits durations to using "days" as the biggest unit. /// let span = dt1.until(dt2)?; - /// assert_eq!(span.to_string(), "P8456dT12h5m29.9999965s"); + /// assert_eq!(span.to_string(), "P8456DT12H5M29.9999965S"); /// /// // But we can ask for units all the way up to years. /// let span = dt1.until((Unit::Year, dt2))?; - /// assert_eq!(span.to_string(), "P23y1m24dT12h5m29.9999965s"); + /// assert_eq!(span.to_string(), "P23Y1M24DT12H5M29.9999965S"); /// # Ok::<(), Box>(()) /// ``` /// diff --git a/src/civil/time.rs b/src/civil/time.rs index f085cdec..15ee4c51 100644 --- a/src/civil/time.rs +++ b/src/civil/time.rs @@ -1198,12 +1198,12 @@ impl Time { /// /// // The default limits spans to using "hours" as the biggest unit. /// let span = t1.until(t2)?; - /// assert_eq!(span.to_string(), "PT12h5m29.9999965s"); + /// assert_eq!(span.to_string(), "PT12H5M29.9999965S"); /// /// // But we can ask for smaller units, like capping the biggest unit /// // to minutes instead of hours. /// let span = t1.until((Unit::Minute, t2))?; - /// assert_eq!(span.to_string(), "PT725m29.9999965s"); + /// assert_eq!(span.to_string(), "PT725M29.9999965S"); /// /// # Ok::<(), Box>(()) /// ``` diff --git a/src/fmt/friendly/mod.rs b/src/fmt/friendly/mod.rs index cb36b741..b47a909b 100644 --- a/src/fmt/friendly/mod.rs +++ b/src/fmt/friendly/mod.rs @@ -77,11 +77,11 @@ format when using the `std::fmt::Display` trait implementation: use jiff::{SignedDuration, ToSpan}; let span = 2.months().days(35).hours(2).minutes(30); -assert_eq!(format!("{span}"), "P2m35dT2h30m"); // ISO 8601 +assert_eq!(format!("{span}"), "P2M35DT2H30M"); // ISO 8601 assert_eq!(format!("{span:#}"), "2mo 35d 2h 30m"); // "friendly" let sdur = SignedDuration::new(2 * 60 * 60 + 30 * 60, 123_456_789); -assert_eq!(format!("{sdur}"), "PT2h30m0.123456789s"); // ISO 8601 +assert_eq!(format!("{sdur}"), "PT2H30M0.123456789S"); // ISO 8601 assert_eq!(format!("{sdur:#}"), "2h 30m 123ms 456µs 789ns"); // "friendly" ``` @@ -467,10 +467,11 @@ P1Y2M3DT4H59M1.1S P1y2m3dT4h59m1.1S ``` -When all of the unit designators are capital letters in particular, everything -runs together and it's hard for the eye to distinguish where digits stop and -letters begin. Using lowercase letters for unit designators helps somewhat, -but this is an extension to ISO 8601 that isn't broadly supported. +When all of the unit designators are capital letters in particular (which +is the default), everything runs together and it's hard for the eye to +distinguish where digits stop and letters begin. Using lowercase letters for +unit designators helps somewhat, but this is an extension to ISO 8601 that +isn't broadly supported. The "friendly" format resolves both of these problems by permitting sub-second components and allowing the use of whitespace and longer unit designator labels diff --git a/src/fmt/friendly/parser.rs b/src/fmt/friendly/parser.rs index 60b6373a..e49c9056 100644 --- a/src/fmt/friendly/parser.rs +++ b/src/fmt/friendly/parser.rs @@ -975,78 +975,78 @@ mod tests { fn parse_span_basic() { let p = |s: &str| SpanParser::new().parse_span(s).unwrap(); - insta::assert_snapshot!(p("5 years"), @"P5y"); - insta::assert_snapshot!(p("5 years 4 months"), @"P5y4m"); - insta::assert_snapshot!(p("5 years 4 months 3 hours"), @"P5y4mT3h"); - insta::assert_snapshot!(p("5 years, 4 months, 3 hours"), @"P5y4mT3h"); + insta::assert_snapshot!(p("5 years"), @"P5Y"); + insta::assert_snapshot!(p("5 years 4 months"), @"P5Y4M"); + insta::assert_snapshot!(p("5 years 4 months 3 hours"), @"P5Y4MT3H"); + insta::assert_snapshot!(p("5 years, 4 months, 3 hours"), @"P5Y4MT3H"); - insta::assert_snapshot!(p("01:02:03"), @"PT1h2m3s"); - insta::assert_snapshot!(p("5 days 01:02:03"), @"P5dT1h2m3s"); + insta::assert_snapshot!(p("01:02:03"), @"PT1H2M3S"); + insta::assert_snapshot!(p("5 days 01:02:03"), @"P5DT1H2M3S"); // This is Python's `str(timedelta)` format! - insta::assert_snapshot!(p("5 days, 01:02:03"), @"P5dT1h2m3s"); - insta::assert_snapshot!(p("3yrs 5 days 01:02:03"), @"P3y5dT1h2m3s"); - insta::assert_snapshot!(p("3yrs 5 days, 01:02:03"), @"P3y5dT1h2m3s"); + insta::assert_snapshot!(p("5 days, 01:02:03"), @"P5DT1H2M3S"); + insta::assert_snapshot!(p("3yrs 5 days 01:02:03"), @"P3Y5DT1H2M3S"); + insta::assert_snapshot!(p("3yrs 5 days, 01:02:03"), @"P3Y5DT1H2M3S"); insta::assert_snapshot!( p("3yrs 5 days, 01:02:03.123456789"), - @"P3y5dT1h2m3.123456789s", + @"P3Y5DT1H2M3.123456789S", ); - insta::assert_snapshot!(p("999:999:999"), @"PT999h999m999s"); + insta::assert_snapshot!(p("999:999:999"), @"PT999H999M999S"); } #[test] fn parse_span_fractional() { let p = |s: &str| SpanParser::new().parse_span(s).unwrap(); - insta::assert_snapshot!(p("1.5hrs"), @"PT1h30m"); - insta::assert_snapshot!(p("1.5mins"), @"PT1m30s"); - insta::assert_snapshot!(p("1.5secs"), @"PT1.5s"); - insta::assert_snapshot!(p("1.5msecs"), @"PT0.0015s"); - insta::assert_snapshot!(p("1.5µsecs"), @"PT0.0000015s"); + insta::assert_snapshot!(p("1.5hrs"), @"PT1H30M"); + insta::assert_snapshot!(p("1.5mins"), @"PT1M30S"); + insta::assert_snapshot!(p("1.5secs"), @"PT1.5S"); + insta::assert_snapshot!(p("1.5msecs"), @"PT0.0015S"); + insta::assert_snapshot!(p("1.5µsecs"), @"PT0.0000015S"); - insta::assert_snapshot!(p("1d 1.5hrs"), @"P1dT1h30m"); - insta::assert_snapshot!(p("1h 1.5mins"), @"PT1h1m30s"); - insta::assert_snapshot!(p("1m 1.5secs"), @"PT1m1.5s"); - insta::assert_snapshot!(p("1s 1.5msecs"), @"PT1.0015s"); - insta::assert_snapshot!(p("1ms 1.5µsecs"), @"PT0.0010015s"); + insta::assert_snapshot!(p("1d 1.5hrs"), @"P1DT1H30M"); + insta::assert_snapshot!(p("1h 1.5mins"), @"PT1H1M30S"); + insta::assert_snapshot!(p("1m 1.5secs"), @"PT1M1.5S"); + insta::assert_snapshot!(p("1s 1.5msecs"), @"PT1.0015S"); + insta::assert_snapshot!(p("1ms 1.5µsecs"), @"PT0.0010015S"); - insta::assert_snapshot!(p("1s2000ms"), @"PT3s"); + insta::assert_snapshot!(p("1s2000ms"), @"PT3S"); } #[test] fn parse_span_boundaries() { let p = |s: &str| SpanParser::new().parse_span(s).unwrap(); - insta::assert_snapshot!(p("19998 years"), @"P19998y"); - insta::assert_snapshot!(p("19998 years ago"), @"-P19998y"); - insta::assert_snapshot!(p("239976 months"), @"P239976m"); - insta::assert_snapshot!(p("239976 months ago"), @"-P239976m"); - insta::assert_snapshot!(p("1043497 weeks"), @"P1043497w"); - insta::assert_snapshot!(p("1043497 weeks ago"), @"-P1043497w"); - insta::assert_snapshot!(p("7304484 days"), @"P7304484d"); - insta::assert_snapshot!(p("7304484 days ago"), @"-P7304484d"); - insta::assert_snapshot!(p("175307616 hours"), @"PT175307616h"); - insta::assert_snapshot!(p("175307616 hours ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("10518456960 minutes"), @"PT10518456960m"); - insta::assert_snapshot!(p("10518456960 minutes ago"), @"-PT10518456960m"); - insta::assert_snapshot!(p("631107417600 seconds"), @"PT631107417600s"); - insta::assert_snapshot!(p("631107417600 seconds ago"), @"-PT631107417600s"); - insta::assert_snapshot!(p("631107417600000 milliseconds"), @"PT631107417600s"); - insta::assert_snapshot!(p("631107417600000 milliseconds ago"), @"-PT631107417600s"); - insta::assert_snapshot!(p("631107417600000000 microseconds"), @"PT631107417600s"); - insta::assert_snapshot!(p("631107417600000000 microseconds ago"), @"-PT631107417600s"); - insta::assert_snapshot!(p("9223372036854775807 nanoseconds"), @"PT9223372036.854775807s"); - insta::assert_snapshot!(p("9223372036854775807 nanoseconds ago"), @"-PT9223372036.854775807s"); - - insta::assert_snapshot!(p("175307617 hours"), @"PT175307616h60m"); - insta::assert_snapshot!(p("175307617 hours ago"), @"-PT175307616h60m"); - insta::assert_snapshot!(p("10518456961 minutes"), @"PT10518456960m60s"); - insta::assert_snapshot!(p("10518456961 minutes ago"), @"-PT10518456960m60s"); - insta::assert_snapshot!(p("631107417601 seconds"), @"PT631107417601s"); - insta::assert_snapshot!(p("631107417601 seconds ago"), @"-PT631107417601s"); - insta::assert_snapshot!(p("631107417600001 milliseconds"), @"PT631107417600.001s"); - insta::assert_snapshot!(p("631107417600001 milliseconds ago"), @"-PT631107417600.001s"); - insta::assert_snapshot!(p("631107417600000001 microseconds"), @"PT631107417600.000001s"); - insta::assert_snapshot!(p("631107417600000001 microseconds ago"), @"-PT631107417600.000001s"); + insta::assert_snapshot!(p("19998 years"), @"P19998Y"); + insta::assert_snapshot!(p("19998 years ago"), @"-P19998Y"); + insta::assert_snapshot!(p("239976 months"), @"P239976M"); + insta::assert_snapshot!(p("239976 months ago"), @"-P239976M"); + insta::assert_snapshot!(p("1043497 weeks"), @"P1043497W"); + insta::assert_snapshot!(p("1043497 weeks ago"), @"-P1043497W"); + insta::assert_snapshot!(p("7304484 days"), @"P7304484D"); + insta::assert_snapshot!(p("7304484 days ago"), @"-P7304484D"); + insta::assert_snapshot!(p("175307616 hours"), @"PT175307616H"); + insta::assert_snapshot!(p("175307616 hours ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("10518456960 minutes"), @"PT10518456960M"); + insta::assert_snapshot!(p("10518456960 minutes ago"), @"-PT10518456960M"); + insta::assert_snapshot!(p("631107417600 seconds"), @"PT631107417600S"); + insta::assert_snapshot!(p("631107417600 seconds ago"), @"-PT631107417600S"); + insta::assert_snapshot!(p("631107417600000 milliseconds"), @"PT631107417600S"); + insta::assert_snapshot!(p("631107417600000 milliseconds ago"), @"-PT631107417600S"); + insta::assert_snapshot!(p("631107417600000000 microseconds"), @"PT631107417600S"); + insta::assert_snapshot!(p("631107417600000000 microseconds ago"), @"-PT631107417600S"); + insta::assert_snapshot!(p("9223372036854775807 nanoseconds"), @"PT9223372036.854775807S"); + insta::assert_snapshot!(p("9223372036854775807 nanoseconds ago"), @"-PT9223372036.854775807S"); + + insta::assert_snapshot!(p("175307617 hours"), @"PT175307616H60M"); + insta::assert_snapshot!(p("175307617 hours ago"), @"-PT175307616H60M"); + insta::assert_snapshot!(p("10518456961 minutes"), @"PT10518456960M60S"); + insta::assert_snapshot!(p("10518456961 minutes ago"), @"-PT10518456960M60S"); + insta::assert_snapshot!(p("631107417601 seconds"), @"PT631107417601S"); + insta::assert_snapshot!(p("631107417601 seconds ago"), @"-PT631107417601S"); + insta::assert_snapshot!(p("631107417600001 milliseconds"), @"PT631107417600.001S"); + insta::assert_snapshot!(p("631107417600001 milliseconds ago"), @"-PT631107417600.001S"); + insta::assert_snapshot!(p("631107417600000001 microseconds"), @"PT631107417600.000001S"); + insta::assert_snapshot!(p("631107417600000001 microseconds ago"), @"-PT631107417600.000001S"); // We don't include nanoseconds here, because that will fail to // parse due to overflowing i64. } @@ -1073,7 +1073,7 @@ mod tests { ); insta::assert_snapshot!( p("1 year 1 mont"), - @r###"failed to parse "1 year 1 mont" in the "friendly" format: parsed value 'P1y1m', but unparsed input "nt" remains (expected no unparsed input)"###, + @r###"failed to parse "1 year 1 mont" in the "friendly" format: parsed value 'P1Y1M', but unparsed input "nt" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("2 months,"), @@ -1085,7 +1085,7 @@ mod tests { ); insta::assert_snapshot!( p("2 months ,"), - @r###"failed to parse "2 months ," in the "friendly" format: parsed value 'P2m', but unparsed input "," remains (expected no unparsed input)"###, + @r###"failed to parse "2 months ," in the "friendly" format: parsed value 'P2M', but unparsed input "," remains (expected no unparsed input)"###, ); } @@ -1095,11 +1095,11 @@ mod tests { insta::assert_snapshot!( p("1yago"), - @r###"failed to parse "1yago" in the "friendly" format: parsed value 'P1y', but unparsed input "ago" remains (expected no unparsed input)"###, + @r###"failed to parse "1yago" in the "friendly" format: parsed value 'P1Y', but unparsed input "ago" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("1 year 1 monthago"), - @r###"failed to parse "1 year 1 monthago" in the "friendly" format: parsed value 'P1y1m', but unparsed input "ago" remains (expected no unparsed input)"###, + @r###"failed to parse "1 year 1 monthago" in the "friendly" format: parsed value 'P1Y1M', but unparsed input "ago" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("+1 year 1 month ago"), @@ -1126,7 +1126,7 @@ mod tests { // one fewer is okay insta::assert_snapshot!( p("640330789636854775 micros"), - @"PT640330789636.854775s" + @"PT640330789636.854775S" ); insta::assert_snapshot!( @@ -1139,7 +1139,7 @@ mod tests { // one fewer is okay insta::assert_snapshot!( p("640330789636854775.807 micros"), - @"PT640330789636.854775807s" + @"PT640330789636.854775807S" ); } @@ -1233,9 +1233,9 @@ mod tests { fn parse_duration_basic() { let p = |s: &str| SpanParser::new().parse_duration(s).unwrap(); - insta::assert_snapshot!(p("1 hour, 2 minutes, 3 seconds"), @"PT1h2m3s"); - insta::assert_snapshot!(p("01:02:03"), @"PT1h2m3s"); - insta::assert_snapshot!(p("999:999:999"), @"PT1015h55m39s"); + insta::assert_snapshot!(p("1 hour, 2 minutes, 3 seconds"), @"PT1H2M3S"); + insta::assert_snapshot!(p("01:02:03"), @"PT1H2M3S"); + insta::assert_snapshot!(p("999:999:999"), @"PT1015H55M39S"); } #[test] @@ -1245,7 +1245,7 @@ mod tests { insta::assert_snapshot!( p("9223372036854775807s"), - @"PT2562047788015215h30m7s", + @"PT2562047788015215H30M7S", ); insta::assert_snapshot!( perr("9223372036854775808s"), @@ -1269,18 +1269,18 @@ mod tests { fn parse_duration_fractional() { let p = |s: &str| SpanParser::new().parse_duration(s).unwrap(); - insta::assert_snapshot!(p("1.5hrs"), @"PT1h30m"); - insta::assert_snapshot!(p("1.5mins"), @"PT1m30s"); - insta::assert_snapshot!(p("1.5secs"), @"PT1.5s"); - insta::assert_snapshot!(p("1.5msecs"), @"PT0.0015s"); - insta::assert_snapshot!(p("1.5µsecs"), @"PT0.0000015s"); + insta::assert_snapshot!(p("1.5hrs"), @"PT1H30M"); + insta::assert_snapshot!(p("1.5mins"), @"PT1M30S"); + insta::assert_snapshot!(p("1.5secs"), @"PT1.5S"); + insta::assert_snapshot!(p("1.5msecs"), @"PT0.0015S"); + insta::assert_snapshot!(p("1.5µsecs"), @"PT0.0000015S"); - insta::assert_snapshot!(p("1h 1.5mins"), @"PT1h1m30s"); - insta::assert_snapshot!(p("1m 1.5secs"), @"PT1m1.5s"); - insta::assert_snapshot!(p("1s 1.5msecs"), @"PT1.0015s"); - insta::assert_snapshot!(p("1ms 1.5µsecs"), @"PT0.0010015s"); + insta::assert_snapshot!(p("1h 1.5mins"), @"PT1H1M30S"); + insta::assert_snapshot!(p("1m 1.5secs"), @"PT1M1.5S"); + insta::assert_snapshot!(p("1s 1.5msecs"), @"PT1.0015S"); + insta::assert_snapshot!(p("1ms 1.5µsecs"), @"PT0.0010015S"); - insta::assert_snapshot!(p("1s2000ms"), @"PT3s"); + insta::assert_snapshot!(p("1s2000ms"), @"PT3S"); } #[test] @@ -1288,51 +1288,51 @@ mod tests { let p = |s: &str| SpanParser::new().parse_duration(s).unwrap(); let pe = |s: &str| SpanParser::new().parse_duration(s).unwrap_err(); - insta::assert_snapshot!(p("175307616 hours"), @"PT175307616h"); - insta::assert_snapshot!(p("175307616 hours ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("10518456960 minutes"), @"PT175307616h"); - insta::assert_snapshot!(p("10518456960 minutes ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("631107417600 seconds"), @"PT175307616h"); - insta::assert_snapshot!(p("631107417600 seconds ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("631107417600000 milliseconds"), @"PT175307616h"); - insta::assert_snapshot!(p("631107417600000 milliseconds ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("631107417600000000 microseconds"), @"PT175307616h"); - insta::assert_snapshot!(p("631107417600000000 microseconds ago"), @"-PT175307616h"); - insta::assert_snapshot!(p("9223372036854775807 nanoseconds"), @"PT2562047h47m16.854775807s"); - insta::assert_snapshot!(p("9223372036854775807 nanoseconds ago"), @"-PT2562047h47m16.854775807s"); - - insta::assert_snapshot!(p("175307617 hours"), @"PT175307617h"); - insta::assert_snapshot!(p("175307617 hours ago"), @"-PT175307617h"); - insta::assert_snapshot!(p("10518456961 minutes"), @"PT175307616h1m"); - insta::assert_snapshot!(p("10518456961 minutes ago"), @"-PT175307616h1m"); - insta::assert_snapshot!(p("631107417601 seconds"), @"PT175307616h1s"); - insta::assert_snapshot!(p("631107417601 seconds ago"), @"-PT175307616h1s"); - insta::assert_snapshot!(p("631107417600001 milliseconds"), @"PT175307616h0.001s"); - insta::assert_snapshot!(p("631107417600001 milliseconds ago"), @"-PT175307616h0.001s"); - insta::assert_snapshot!(p("631107417600000001 microseconds"), @"PT175307616h0.000001s"); - insta::assert_snapshot!(p("631107417600000001 microseconds ago"), @"-PT175307616h0.000001s"); + insta::assert_snapshot!(p("175307616 hours"), @"PT175307616H"); + insta::assert_snapshot!(p("175307616 hours ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("10518456960 minutes"), @"PT175307616H"); + insta::assert_snapshot!(p("10518456960 minutes ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("631107417600 seconds"), @"PT175307616H"); + insta::assert_snapshot!(p("631107417600 seconds ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("631107417600000 milliseconds"), @"PT175307616H"); + insta::assert_snapshot!(p("631107417600000 milliseconds ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("631107417600000000 microseconds"), @"PT175307616H"); + insta::assert_snapshot!(p("631107417600000000 microseconds ago"), @"-PT175307616H"); + insta::assert_snapshot!(p("9223372036854775807 nanoseconds"), @"PT2562047H47M16.854775807S"); + insta::assert_snapshot!(p("9223372036854775807 nanoseconds ago"), @"-PT2562047H47M16.854775807S"); + + insta::assert_snapshot!(p("175307617 hours"), @"PT175307617H"); + insta::assert_snapshot!(p("175307617 hours ago"), @"-PT175307617H"); + insta::assert_snapshot!(p("10518456961 minutes"), @"PT175307616H1M"); + insta::assert_snapshot!(p("10518456961 minutes ago"), @"-PT175307616H1M"); + insta::assert_snapshot!(p("631107417601 seconds"), @"PT175307616H1S"); + insta::assert_snapshot!(p("631107417601 seconds ago"), @"-PT175307616H1S"); + insta::assert_snapshot!(p("631107417600001 milliseconds"), @"PT175307616H0.001S"); + insta::assert_snapshot!(p("631107417600001 milliseconds ago"), @"-PT175307616H0.001S"); + insta::assert_snapshot!(p("631107417600000001 microseconds"), @"PT175307616H0.000001S"); + insta::assert_snapshot!(p("631107417600000001 microseconds ago"), @"-PT175307616H0.000001S"); // We don't include nanoseconds here, because that will fail to // parse due to overflowing i64. // The above were copied from the corresponding `Span` test, which has // tighter limits on components. But a `SignedDuration` supports the // full range of `i64` seconds. - insta::assert_snapshot!(p("2562047788015215hours"), @"PT2562047788015215h"); - insta::assert_snapshot!(p("-2562047788015215hours"), @"-PT2562047788015215h"); + insta::assert_snapshot!(p("2562047788015215hours"), @"PT2562047788015215H"); + insta::assert_snapshot!(p("-2562047788015215hours"), @"-PT2562047788015215H"); insta::assert_snapshot!( pe("2562047788015216hrs"), @r###"failed to parse "2562047788015216hrs" in the "friendly" format: converting 2562047788015216 hours to seconds overflows i64"###, ); - insta::assert_snapshot!(p("153722867280912930minutes"), @"PT2562047788015215h30m"); - insta::assert_snapshot!(p("153722867280912930minutes ago"), @"-PT2562047788015215h30m"); + insta::assert_snapshot!(p("153722867280912930minutes"), @"PT2562047788015215H30M"); + insta::assert_snapshot!(p("153722867280912930minutes ago"), @"-PT2562047788015215H30M"); insta::assert_snapshot!( pe("153722867280912931mins"), @r###"failed to parse "153722867280912931mins" in the "friendly" format: parameter 'minutes-to-seconds' with value 60 is not in the required range of -9223372036854775808..=9223372036854775807"###, ); - insta::assert_snapshot!(p("9223372036854775807seconds"), @"PT2562047788015215h30m7s"); - insta::assert_snapshot!(p("-9223372036854775807seconds"), @"-PT2562047788015215h30m7s"); + insta::assert_snapshot!(p("9223372036854775807seconds"), @"PT2562047788015215H30M7S"); + insta::assert_snapshot!(p("-9223372036854775807seconds"), @"-PT2562047788015215H30M7S"); insta::assert_snapshot!( pe("9223372036854775808s"), @r###"failed to parse "9223372036854775808s" in the "friendly" format: number '9223372036854775808' too big to parse into 64-bit integer"###, @@ -1369,7 +1369,7 @@ mod tests { ); insta::assert_snapshot!( p("1 hour 1 minut"), - @r###"failed to parse "1 hour 1 minut" in the "friendly" format: parsed value 'PT1h1m', but unparsed input "ut" remains (expected no unparsed input)"###, + @r###"failed to parse "1 hour 1 minut" in the "friendly" format: parsed value 'PT1H1M', but unparsed input "ut" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("2 minutes,"), @@ -1381,7 +1381,7 @@ mod tests { ); insta::assert_snapshot!( p("2 minutes ,"), - @r###"failed to parse "2 minutes ," in the "friendly" format: parsed value 'PT2m', but unparsed input "," remains (expected no unparsed input)"###, + @r###"failed to parse "2 minutes ," in the "friendly" format: parsed value 'PT2M', but unparsed input "," remains (expected no unparsed input)"###, ); } @@ -1391,11 +1391,11 @@ mod tests { insta::assert_snapshot!( p("1hago"), - @r###"failed to parse "1hago" in the "friendly" format: parsed value 'PT1h', but unparsed input "ago" remains (expected no unparsed input)"###, + @r###"failed to parse "1hago" in the "friendly" format: parsed value 'PT1H', but unparsed input "ago" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("1 hour 1 minuteago"), - @r###"failed to parse "1 hour 1 minuteago" in the "friendly" format: parsed value 'PT1h1m', but unparsed input "ago" remains (expected no unparsed input)"###, + @r###"failed to parse "1 hour 1 minuteago" in the "friendly" format: parsed value 'PT1H1M', but unparsed input "ago" remains (expected no unparsed input)"###, ); insta::assert_snapshot!( p("+1 hour 1 minute ago"), @@ -1421,7 +1421,7 @@ mod tests { // one fewer is okay insta::assert_snapshot!( p("9223372036854775807 micros"), - @"PT2562047788h54.775807s" + @"PT2562047788H54.775807S" ); } diff --git a/src/fmt/friendly/printer.rs b/src/fmt/friendly/printer.rs index 8a4b4cdd..330ddfc1 100644 --- a/src/fmt/friendly/printer.rs +++ b/src/fmt/friendly/printer.rs @@ -462,6 +462,7 @@ pub struct SpanPrinter { hms: bool, padding: Option, precision: Option, + zero_unit: Unit, } impl SpanPrinter { @@ -497,6 +498,7 @@ impl SpanPrinter { hms: false, padding: None, precision: None, + zero_unit: Unit::Second, } } @@ -907,6 +909,65 @@ impl SpanPrinter { SpanPrinter { precision, ..self } } + /// Sets the unit to use when printing a duration that is zero. + /// + /// When [`SpanPrinter::fractional`] is set, then this setting is ignored + /// and the zero unit corresponds to the fractional unit specified. + /// + /// This defaults to [`Unit::Second`]. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::friendly::{FractionalUnit, SpanPrinter}, ToSpan, Unit}; + /// + /// // The default just always uses seconds. + /// let printer = SpanPrinter::new(); + /// assert_eq!(printer.span_to_string(&0.years()), "0s"); + /// + /// // We can set our own unit. + /// let printer = SpanPrinter::new().zero_unit(Unit::Year); + /// assert_eq!(printer.span_to_string(&0.years()), "0y"); + /// + /// // But it's overridden if fractional units are set. + /// let printer = SpanPrinter::new() + /// .zero_unit(Unit::Year) + /// .fractional(Some(FractionalUnit::Minute)); + /// assert_eq!(printer.span_to_string(&0.years()), "0m"); + /// + /// // One use case for this option is if you're rounding + /// // spans and want the zero unit to reflect the smallest + /// // unit you're using. + /// let printer = SpanPrinter::new().zero_unit(Unit::Minute); + /// let span = 5.hours().minutes(30).seconds(59); + /// let rounded = span.round(Unit::Minute)?; + /// assert_eq!(printer.span_to_string(&rounded), "5h 31m"); + /// + /// let span = 5.seconds(); + /// let rounded = span.round(Unit::Minute)?; + /// assert_eq!(printer.span_to_string(&rounded), "0m"); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// The same applies for `SignedDuration`: + /// + /// ``` + /// use jiff::{fmt::friendly::SpanPrinter, SignedDuration, Unit}; + /// + /// // The default just always uses seconds. + /// let printer = SpanPrinter::new(); + /// assert_eq!(printer.duration_to_string(&SignedDuration::ZERO), "0s"); + /// + /// // We can set our own unit. + /// let printer = SpanPrinter::new().zero_unit(Unit::Minute); + /// assert_eq!(printer.duration_to_string(&SignedDuration::ZERO), "0m"); + /// ``` + #[inline] + pub const fn zero_unit(self, unit: Unit) -> SpanPrinter { + SpanPrinter { zero_unit: unit, ..self } + } + /// Format a `Span` into a string using the "friendly" format. /// /// This is a convenience routine for [`SpanPrinter::print_span`] with a @@ -1462,8 +1523,11 @@ impl<'p, 'w, W: Write> DesignatorWriter<'p, 'w, W> { } // If a fractional unit is set, then we should use that unit // specifically to express "zero." - let unit = - self.printer.fractional.map(Unit::from).unwrap_or(Unit::Second); + let unit = self + .printer + .fractional + .map(Unit::from) + .unwrap_or(self.printer.zero_unit); self.wtr.write_int(&self.fmtint, 0)?; self.wtr .write_str(self.printer.spacing.between_units_and_designators())?; diff --git a/src/fmt/offset.rs b/src/fmt/offset.rs index 96da13b0..3b1a13ef 100644 --- a/src/fmt/offset.rs +++ b/src/fmt/offset.rs @@ -106,6 +106,7 @@ from [Temporal's hybrid grammar]. use crate::{ error::{err, Error, ErrorContext}, fmt::{ + temporal::{PiecesNumericOffset, PiecesOffset}, util::{parse_temporal_fraction, FractionalFormatter}, Parsed, }, @@ -156,6 +157,24 @@ impl ParsedOffset { } } + /// Convert a parsed offset to a more structured representation. + /// + /// This is like `to_offset`, but preserves `Z` and `-00:00` versus + /// `+00:00`. This does still attempt to create an `Offset`, and that + /// construction can fail. + pub(crate) fn to_pieces_offset(&self) -> Result { + match self.kind { + ParsedOffsetKind::Zulu => Ok(PiecesOffset::Zulu), + ParsedOffsetKind::Numeric(ref numeric) => { + let mut off = PiecesNumericOffset::from(numeric.to_offset()?); + if numeric.sign < 0 { + off = off.with_negative_zero(); + } + Ok(PiecesOffset::from(off)) + } + } + } + /// Whether this parsed offset corresponds to Zulu time or not. /// /// This is useful in error reporting for parsing civil times. Namely, we diff --git a/src/fmt/rfc9557.rs b/src/fmt/rfc9557.rs index e50cf209..817992f1 100644 --- a/src/fmt/rfc9557.rs +++ b/src/fmt/rfc9557.rs @@ -98,9 +98,9 @@ use crate::{ error::{err, Error}, fmt::{ offset::{self, ParsedOffset}, + temporal::{TimeZoneAnnotation, TimeZoneAnnotationKind}, Parsed, }, - tz::{TimeZone, TimeZoneDatabase}, util::{escape, parse}, }; @@ -130,41 +130,16 @@ impl<'i> ParsedAnnotations<'i> { ParsedAnnotations { input: escape::Bytes(&[]), time_zone: None } } - /// If a time zone annotation was parsed, then this returns the annotation - /// converted to a `TimeZone`, along with a flag indicating whether it - /// is "critical" or not. (When it's "critical," there should be more - /// stringent validation.) + /// Turns this parsed time zone into a structured time zone annotation, + /// if an annotation was found. Otherwise, returns `Ok(None)`. /// - /// If the time zone annotation parsed successfully but was either not - /// found in the database given or otherwise invalid, then an error is - /// returned. - /// - /// `None` is returned only when there was no time zone annotation. - pub(crate) fn to_time_zone( + /// This can return an error if the parsed offset could not be converted + /// to a `crate::tz::Offset`. + pub(crate) fn to_time_zone_annotation( &self, - db: &TimeZoneDatabase, - ) -> Result, Error> { + ) -> Result>, Error> { let Some(ref parsed) = self.time_zone else { return Ok(None) }; - // NOTE: We don't currently utilize the critical flag here. Temporal - // seems to ignore it. It's not quite clear what else we'd do with it, - // particularly given that we provide a way to do conflict resolution - // between offsets and time zones. - match *parsed { - ParsedTimeZone::Named { critical, name } => { - let tz = match db.get(name) { - Ok(tz) => tz, - Err(err) => return Err(err!("{}", err)), - }; - Ok(Some((tz, critical))) - } - ParsedTimeZone::Offset { critical, ref offset } => { - let offset = match offset.to_offset() { - Ok(offset) => offset, - Err(err) => return Err(err), - }; - Ok(Some((TimeZone::fixed(offset), critical))) - } - } + Ok(Some(parsed.to_time_zone_annotation()?)) } } @@ -187,6 +162,31 @@ enum ParsedTimeZone<'i> { }, } +impl<'i> ParsedTimeZone<'i> { + /// Turns this parsed time zone into a structured time zone annotation. + /// + /// This can return an error if the parsed offset could not be converted + /// to a `crate::tz::Offset`. + /// + /// This also includes a flag of whether the annotation is "critical" or + /// not. + pub(crate) fn to_time_zone_annotation( + &self, + ) -> Result, Error> { + let (kind, critical) = match *self { + ParsedTimeZone::Named { name, critical } => { + let kind = TimeZoneAnnotationKind::from(name); + (kind, critical) + } + ParsedTimeZone::Offset { ref offset, critical } => { + let kind = TimeZoneAnnotationKind::Offset(offset.to_offset()?); + (kind, critical) + } + }; + Ok(TimeZoneAnnotation { kind, critical }) + } +} + /// A parser for RFC 9557 annotations. #[derive(Debug)] pub(crate) struct Parser { @@ -603,8 +603,9 @@ mod tests { .parse(input) .unwrap() .value - .to_time_zone(crate::tz::db()) + .to_time_zone_annotation() .unwrap() + .map(|ann| (ann.to_time_zone().unwrap(), ann.is_critical())) }; insta::assert_debug_snapshot!(p(b"[America/New_York]"), @r###" @@ -1024,13 +1025,16 @@ mod tests { #[cfg(feature = "std")] #[test] - fn err_time_zone() { + fn err_time_zone_db_lookup() { let p = |input| { Parser::new() .parse(input) .unwrap() .value - .to_time_zone(crate::tz::db()) + .to_time_zone_annotation() + .unwrap() + .unwrap() + .to_time_zone() .unwrap_err() }; diff --git a/src/fmt/temporal/mod.rs b/src/fmt/temporal/mod.rs index 95f898a4..446d4c95 100644 --- a/src/fmt/temporal/mod.rs +++ b/src/fmt/temporal/mod.rs @@ -68,8 +68,11 @@ But there are some details not easily captured by a simple regular expression: * At least one unit must be specified. To write a zero span, specify `0` for any unit. For example, `P0d` and `PT0s` are equivalent. -* The format is case insensitive. The printer will by default capitalize the -`P` and `T` designators, but lowercase the unit designators. +* The format is case insensitive. The printer will by default capitalize all +designators, but the unit designators can be configured to use lowercase with +[`SpanPrinter::lowercase`]. For example, `P3y1m10dT5h` instead of +`P3Y1M10DT5H`. You might prefer lowercase since you may find it easier to read. +However, it is an extension to ISO 8601 and isn't as broadly supported. * Hours, minutes or seconds may be fractional. And the only units that may be fractional are the lowest units. * A span like `P99999999999y` is invalid because it exceeds the allowable range @@ -172,7 +175,13 @@ use crate::{ SignedDuration, Timestamp, Zoned, }; +pub use self::pieces::{ + Pieces, PiecesNumericOffset, PiecesOffset, TimeZoneAnnotation, + TimeZoneAnnotationKind, TimeZoneAnnotationName, +}; + mod parser; +mod pieces; mod printer; /// The default date time parser that we use throughout Jiff. @@ -750,6 +759,129 @@ impl DateTimeParser { let time = parsed_time.to_time(); Ok(time) } + + /// Parse a Temporal datetime string into [`Pieces`]. + /// + /// This is a lower level routine meant to give callers raw access to the + /// individual "pieces" of a parsed Temporal ISO 8601 datetime string. + /// Note that this only includes strings that have a date component. + /// + /// The benefit of this routine is that it only checks that the datetime + /// is itself valid. It doesn't do any automatic diambiguation, offset + /// conflict resolution or attempt to prevent you from shooting yourself + /// in the foot. For example, this routine will let you parse a fixed + /// offset datetime into a `Zoned` without a time zone abbreviation. + /// + /// Note that when using this routine, the + /// [`DateTimeParser::offset_conflict`] and + /// [`DateTimeParser::disambiguation`] configuration knobs are completely + /// ignored. This is because with the lower level `Pieces`, callers must + /// handle offset conflict resolution (if they want it) themselves. See + /// the [`Pieces`] documentation for a case study on how to do this if + /// you need it. + /// + /// # Errors + /// + /// This returns an error if the datetime string given is invalid or if it + /// is valid but doesn't fit in the date range supported by Jiff. + /// + /// # Example + /// + /// This shows how to parse a fixed offset timestamp into a `Zoned`. + /// + /// ``` + /// use jiff::{fmt::temporal::DateTimeParser, tz::TimeZone}; + /// + /// static PARSER: DateTimeParser = DateTimeParser::new(); + /// + /// let timestamp = "2025-01-02T15:13-05"; + /// + /// // Normally this operation will fail. + /// assert_eq!( + /// PARSER.parse_zoned(timestamp).unwrap_err().to_string(), + /// "failed to find time zone in square brackets in \ + /// \"2025-01-02T15:13-05\", which is required for \ + /// parsing a zoned instant", + /// ); + /// + /// // But you can work-around this with `Pieces`, which gives you direct + /// // access to the components parsed from the string. + /// let pieces = PARSER.parse_pieces(timestamp)?; + /// let time = pieces.time().unwrap_or_else(jiff::civil::Time::midnight); + /// let dt = pieces.date().to_datetime(time); + /// let tz = match pieces.to_time_zone()? { + /// Some(tz) => tz, + /// None => { + /// let Some(offset) = pieces.to_numeric_offset() else { + /// let msg = format!( + /// "timestamp `{timestamp}` has no time zone \ + /// or offset, and thus cannot be parsed into \ + /// an instant", + /// ); + /// return Err(msg.into()); + /// }; + /// TimeZone::fixed(offset) + /// } + /// }; + /// // We don't bother with offset conflict resolution. And note that + /// // this uses automatic "compatible" disambiguation in the case of + /// // discontinuities. Of course, this is all moot if `TimeZone` is + /// // fixed. The above code handles the case where it isn't! + /// let zdt = tz.to_zoned(dt)?; + /// assert_eq!(zdt.to_string(), "2025-01-02T15:13:00-05:00[-05:00]"); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Example: work around errors when a `Z` (Zulu) offset is encountered + /// + /// Because parsing a date with a `Z` offset and interpreting it as + /// a civil date or time is usually a bug, it is forbidden: + /// + /// ``` + /// use jiff::{civil::date, fmt::temporal::DateTimeParser}; + /// + /// static PARSER: DateTimeParser = DateTimeParser::new(); + /// + /// assert_eq!( + /// PARSER.parse_date("2024-03-10T00:00:00Z").unwrap_err().to_string(), + /// "cannot parse civil date from string with a Zulu offset, \ + /// parse as a `Timestamp` and convert to a civil date instead", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// But this sort of error checking doesn't happen when you parse into a + /// [`Pieces`]. You just get what was parsed, which lets you extract a + /// date even if the higher level APIs forbid it: + /// + /// ``` + /// use jiff::{civil, fmt::temporal::DateTimeParser, tz::Offset}; + /// + /// static PARSER: DateTimeParser = DateTimeParser::new(); + /// + /// let pieces = PARSER.parse_pieces("2024-03-10T00:00:00Z")?; + /// assert_eq!(pieces.date(), civil::date(2024, 3, 10)); + /// assert_eq!(pieces.time(), Some(civil::time(0, 0, 0, 0))); + /// assert_eq!(pieces.to_numeric_offset(), Some(Offset::UTC)); + /// assert_eq!(pieces.to_time_zone()?, None); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// This is usually not the right thing to do. It isn't even suggested in + /// the error message above. But if you know it's the right thing, then + /// `Pieces` will let you do it. + pub fn parse_pieces<'i, I: ?Sized + AsRef<[u8]> + 'i>( + &self, + input: &'i I, + ) -> Result, Error> { + let input = input.as_ref(); + let parsed = self.p.parse_temporal_datetime(input)?.into_full()?; + let pieces = parsed.to_pieces()?; + Ok(pieces) + } } /// A printer for Temporal datetimes. @@ -1159,6 +1291,50 @@ impl DateTimePrinter { buf } + /// Format `Pieces` of a Temporal datetime. + /// + /// This is a convenience routine for [`DateTimePrinter::print_pieces`] + /// with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{ + /// fmt::temporal::{DateTimePrinter, Pieces}, + /// tz::offset, + /// Timestamp, + /// }; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let pieces = Pieces::from(Timestamp::UNIX_EPOCH); + /// assert_eq!( + /// PRINTER.pieces_to_string(&pieces), + /// "1970-01-01T00:00:00Z", + /// ); + /// + /// let pieces = Pieces::from((Timestamp::UNIX_EPOCH, offset(0))); + /// assert_eq!( + /// PRINTER.pieces_to_string(&pieces), + /// "1970-01-01T00:00:00+00:00", + /// ); + /// + /// let pieces = Pieces::from((Timestamp::UNIX_EPOCH, offset(-5))); + /// assert_eq!( + /// PRINTER.pieces_to_string(&pieces), + /// "1969-12-31T19:00:00-05:00", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[cfg(feature = "alloc")] + pub fn pieces_to_string(&self, pieces: &Pieces) -> alloc::string::String { + let mut buf = alloc::string::String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_pieces(pieces, &mut buf).unwrap(); + buf + } + /// Print a `Zoned` datetime to the given writer. /// /// # Errors @@ -1409,6 +1585,40 @@ impl DateTimePrinter { ) -> Result<(), Error> { self.p.print_time(time, wtr) } + + /// Print the `Pieces` of a Temporal datetime. + /// + /// # Errors + /// + /// This only returns an error when writing to the given [`Write`] + /// implementation would fail. Some such implementations, like for `String` + /// and `Vec`, never fail (unless memory allocation fails). In such + /// cases, it would be appropriate to call `unwrap()` on the result. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::date, fmt::temporal::{DateTimePrinter, Pieces}}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let pieces = Pieces::from(date(2024, 6, 15)) + /// .with_time_zone_name("US/Eastern"); + /// + /// let mut buf = String::new(); + /// // Printing to a `String` can never fail. + /// PRINTER.print_pieces(&pieces, &mut buf).unwrap(); + /// assert_eq!(buf, "2024-06-15[US/Eastern]"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn print_pieces( + &self, + pieces: &Pieces, + wtr: W, + ) -> Result<(), Error> { + self.p.print_pieces(pieces, wtr) + } } /// A parser for Temporal durations. @@ -1566,7 +1776,7 @@ impl SpanParser { /// let mut buf = vec![]; /// // Printing to a `Vec` can never fail. /// PRINTER.print_span(&span, &mut buf).unwrap(); -/// assert_eq!(buf, "PT48m".as_bytes()); +/// assert_eq!(buf, "PT48M".as_bytes()); /// ``` /// /// # Example: using adapters with `std::io::Write` and `std::fmt::Write` @@ -1605,6 +1815,30 @@ impl SpanPrinter { SpanPrinter { p: printer::SpanPrinter::new() } } + /// Use lowercase for unit designator labels. + /// + /// By default, unit designator labels are written in uppercase. + /// + /// # Example + /// + /// This shows the difference between the default (uppercase) and enabling + /// lowercase. Lowercase unit designator labels tend to be easier to read + /// (in this author's opinion), but they aren't as broadly supported since + /// they are an extension to ISO 8601. + /// + /// ``` + /// use jiff::{fmt::temporal::SpanPrinter, ToSpan}; + /// + /// let span = 5.years().days(10).hours(1); + /// let printer = SpanPrinter::new(); + /// assert_eq!(printer.span_to_string(&span), "P5Y10DT1H"); + /// assert_eq!(printer.lowercase(true).span_to_string(&span), "P5y10dT1h"); + /// ``` + #[inline] + pub const fn lowercase(self, yes: bool) -> SpanPrinter { + SpanPrinter { p: self.p.lowercase(yes) } + } + /// Format a `Span` into a string. /// /// This is a convenience routine for [`SpanPrinter::print_span`] with @@ -1618,7 +1852,7 @@ impl SpanPrinter { /// const PRINTER: SpanPrinter = SpanPrinter::new(); /// /// let span = 3.years().months(5); - /// assert_eq!(PRINTER.span_to_string(&span), "P3y5m"); + /// assert_eq!(PRINTER.span_to_string(&span), "P3Y5M"); /// /// # Ok::<(), Box>(()) /// ``` @@ -1646,8 +1880,8 @@ impl SpanPrinter { /// const PRINTER: SpanPrinter = SpanPrinter::new(); /// /// let dur = SignedDuration::new(86_525, 123_000_789); - /// assert_eq!(PRINTER.duration_to_string(&dur), "PT24h2m5.123000789s"); - /// assert_eq!(PRINTER.duration_to_string(&-dur), "-PT24h2m5.123000789s"); + /// assert_eq!(PRINTER.duration_to_string(&dur), "PT24H2M5.123000789S"); + /// assert_eq!(PRINTER.duration_to_string(&-dur), "-PT24H2M5.123000789S"); /// /// # Ok::<(), Box>(()) /// ``` @@ -1683,7 +1917,7 @@ impl SpanPrinter { /// let mut buf = String::new(); /// // Printing to a `String` can never fail. /// PRINTER.print_span(&span, &mut buf).unwrap(); - /// assert_eq!(buf, "P3y5m"); + /// assert_eq!(buf, "P3Y5M"); /// /// # Ok::<(), Box>(()) /// ``` @@ -1719,12 +1953,12 @@ impl SpanPrinter { /// let mut buf = String::new(); /// // Printing to a `String` can never fail. /// PRINTER.print_duration(&dur, &mut buf).unwrap(); - /// assert_eq!(buf, "PT24h2m5.123000789s"); + /// assert_eq!(buf, "PT24H2M5.123000789S"); /// /// // Negative durations are supported. /// buf.clear(); /// PRINTER.print_duration(&-dur, &mut buf).unwrap(); - /// assert_eq!(buf, "-PT24h2m5.123000789s"); + /// assert_eq!(buf, "-PT24H2M5.123000789S"); /// /// # Ok::<(), Box>(()) /// ``` diff --git a/src/fmt/temporal/parser.rs b/src/fmt/temporal/parser.rs index c0c521cb..e10cffa5 100644 --- a/src/fmt/temporal/parser.rs +++ b/src/fmt/temporal/parser.rs @@ -4,6 +4,7 @@ use crate::{ fmt::{ offset::{self, ParsedOffset}, rfc9557::{self, ParsedAnnotations}, + temporal::Pieces, util::{ fractional_time_to_duration, fractional_time_to_span, parse_temporal_fraction, @@ -35,6 +36,21 @@ pub(super) struct ParsedDateTime<'i> { } impl<'i> ParsedDateTime<'i> { + #[inline(always)] + pub(super) fn to_pieces(&self) -> Result, Error> { + let mut pieces = Pieces::from(self.date.date); + if let Some(ref time) = self.time { + pieces = pieces.with_time(time.time); + } + if let Some(ref offset) = self.offset { + pieces = pieces.with_offset(offset.to_pieces_offset()?); + } + if let Some(ann) = self.annotations.to_time_zone_annotation()? { + pieces = pieces.with_time_zone_annotation(ann); + } + Ok(pieces) + } + #[inline(always)] pub(super) fn to_zoned( &self, @@ -56,14 +72,15 @@ impl<'i> ParsedDateTime<'i> { let dt = DateTime::from_parts(self.date.date, time); // We always require a time zone when parsing a zoned instant. - let (tz, _critical) = - self.annotations.to_time_zone(db)?.ok_or_else(|| { + let tz_annotation = + self.annotations.to_time_zone_annotation()?.ok_or_else(|| { err!( "failed to find time zone in square brackets \ in {:?}, which is required for parsing a zoned instant", self.input, ) })?; + let tz = tz_annotation.to_time_zone_with(db)?; // If there's no offset, then our only choice, regardless of conflict // resolution preference, is to use the time zone. That is, there is no diff --git a/src/fmt/temporal/pieces.rs b/src/fmt/temporal/pieces.rs new file mode 100644 index 00000000..7fcf6682 --- /dev/null +++ b/src/fmt/temporal/pieces.rs @@ -0,0 +1,1725 @@ +use crate::{ + civil::{Date, DateTime, Time}, + error::Error, + tz::{Offset, TimeZone, TimeZoneDatabase}, + util::borrow::StringCow, + Timestamp, Zoned, +}; + +/// A low level representation of a parsed Temporal ISO 8601 datetime string. +/// +/// Most users should not need to use or care about this type. Its purpose is +/// to represent the individual components of a datetime string for more +/// flexible parsing when use cases call for it. +/// +/// One can parse into `Pieces` via [`Pieces::parse`]. Its date, time +/// (optional), offset (optional) and time zone annotation (optional) can be +/// queried independently. Each component corresponds to the following in a +/// datetime string: +/// +/// ```text +/// {date}T{time}{offset}[{time-zone-annotation}] +/// ``` +/// +/// For example: +/// +/// ```text +/// 2025-01-03T19:54-05[America/New_York] +/// ``` +/// +/// A date is the only required component. +/// +/// A `Pieces` can also be constructed from structured values via its `From` +/// trait implementations. The `From` trait has the following implementations +/// available: +/// +/// * `From` creates a `Pieces` with just a civil [`Date`]. All other +/// components are left empty. +/// * `From` creates a `Pieces` with a civil [`Date`] and [`Time`]. +/// The offset and time zone annotation are left empty. +/// * `From` creates a `Pieces` from a [`Timestamp`] using +/// a Zulu offset. This signifies that the precise instant is known, but the +/// local time's offset from UTC is unknown. The [`Date`] and [`Time`] are +/// determined via `Offset::UTC.to_datetime(timestamp)`. The time zone +/// annotation is left empty. +/// * `From<(Timestamp, Offset)>` creates a `Pieces` from a [`Timestamp`] and +/// an [`Offset`]. The [`Date`] and [`Time`] are determined via +/// `offset.to_datetime(timestamp)`. The time zone annotation is left empty. +/// * `From<&Zoned>` creates a `Pieces` from a [`Zoned`]. This populates all +/// fields of a `Pieces`. +/// +/// A `Pieces` can be converted to a Temporal ISO 8601 string via its `Display` +/// trait implementation. +/// +/// # Example: distinguishing between `Z`, `+00:00` and `-00:00` +/// +/// With `Pieces`, it's possible to parse a datetime string and inspect the +/// "type" of its offset when it is zero. This makes use of the +/// [`PiecesOffset`] and [`PiecesNumericOffset`] auxiliary types. +/// +/// ``` +/// use jiff::{ +/// fmt::temporal::{Pieces, PiecesNumericOffset, PiecesOffset}, +/// tz::Offset, +/// }; +/// +/// let pieces = Pieces::parse("1970-01-01T00:00:00Z")?; +/// let off = pieces.offset().unwrap(); +/// // Parsed as Zulu. +/// assert_eq!(off, PiecesOffset::Zulu); +/// // Gets converted from Zulu to UTC, i.e., just zero. +/// assert_eq!(off.to_numeric_offset(), Offset::UTC); +/// +/// let pieces = Pieces::parse("1970-01-01T00:00:00-00:00")?; +/// let off = pieces.offset().unwrap(); +/// // Parsed as a negative zero. +/// assert_eq!(off, PiecesOffset::from( +/// PiecesNumericOffset::from(Offset::UTC).with_negative_zero(), +/// )); +/// // Gets converted from -00:00 to UTC, i.e., just zero. +/// assert_eq!(off.to_numeric_offset(), Offset::UTC); +/// +/// let pieces = Pieces::parse("1970-01-01T00:00:00+00:00")?; +/// let off = pieces.offset().unwrap(); +/// // Parsed as a positive zero. +/// assert_eq!(off, PiecesOffset::from( +/// PiecesNumericOffset::from(Offset::UTC), +/// )); +/// // Gets converted from -00:00 to UTC, i.e., just zero. +/// assert_eq!(off.to_numeric_offset(), Offset::UTC); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// It's rare to need to care about these differences, but the above example +/// demonstrates that `Pieces` doesn't try to do any automatic translation for +/// you. +/// +/// # Example: it is very easy to misuse `Pieces` +/// +/// This example shows how easily you can shoot yourself in the foot with +/// `Pieces`: +/// +/// ``` +/// use jiff::{fmt::temporal::{Pieces, TimeZoneAnnotation}, tz}; +/// +/// let mut pieces = Pieces::parse("2025-01-03T07:55+02[Africa/Cairo]")?; +/// pieces = pieces.with_offset(tz::offset(-10)); +/// // This is nonsense because the offset isn't compatible with the time zone! +/// // Moreover, the actual instant that this timestamp represents has changed. +/// assert_eq!(pieces.to_string(), "2025-01-03T07:55:00-10:00[Africa/Cairo]"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// In the above example, we take a parsed `Pieces`, change its offset and +/// then format it back into a string. There are no speed bumps or errors. +/// A `Pieces` will just blindly follow your instruction, even if it produces +/// a nonsense result. Nonsense results are still parsable back into `Pieces`: +/// +/// ``` +/// use jiff::{civil, fmt::temporal::Pieces, tz::{TimeZone, offset}}; +/// +/// let pieces = Pieces::parse("2025-01-03T07:55:00-10:00[Africa/Cairo]")?; +/// assert_eq!(pieces.date(), civil::date(2025, 1, 3)); +/// assert_eq!(pieces.time(), Some(civil::time(7, 55, 0, 0))); +/// assert_eq!(pieces.to_numeric_offset(), Some(offset(-10))); +/// assert_eq!(pieces.to_time_zone()?, Some(TimeZone::get("Africa/Cairo")?)); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// This exemplifies that `Pieces` is a mostly "dumb" type that passes +/// through the data it contains, even if it doesn't make sense. +/// +/// # Case study: how to parse `2025-01-03T17:28-05` into `Zoned` +/// +/// One thing in particular that `Pieces` enables callers to do is side-step +/// some of the stricter requirements placed on the higher level parsing +/// functions (such as `Zoned`'s `FromStr` trait implementation). For example, +/// parsing a datetime string into a `Zoned` _requires_ that the string contain +/// a time zone annotation. Namely, parsing `2025-01-03T17:28-05` into a +/// `Zoned` will fail: +/// +/// ``` +/// use jiff::Zoned; +/// +/// assert_eq!( +/// "2025-01-03T17:28-05".parse::().unwrap_err().to_string(), +/// "failed to find time zone in square brackets in \ +/// \"2025-01-03T17:28-05\", which is required for \ +/// parsing a zoned instant", +/// ); +/// ``` +/// +/// The above fails because an RFC 3339 timestamp only contains an offset, +/// not a time zone, and thus the resulting `Zoned` could never do time zone +/// aware arithmetic. +/// +/// However, in some cases, you might want to bypass these protections and +/// creat a `Zoned` value with a fixed offset time zone anyway. For example, +/// perhaps your use cases don't need time zone aware arithmetic, but want to +/// preserve the offset anyway. This can be accomplished with `Pieces`: +/// +/// ``` +/// use jiff::{fmt::temporal::Pieces, tz::TimeZone}; +/// +/// let pieces = Pieces::parse("2025-01-03T17:28-05")?; +/// let time = pieces.time().unwrap_or_else(jiff::civil::Time::midnight); +/// let dt = pieces.date().to_datetime(time); +/// let Some(offset) = pieces.to_numeric_offset() else { +/// let msg = format!( +/// "datetime string has no offset, \ +/// and thus cannot be parsed into an instant", +/// ); +/// return Err(msg.into()); +/// }; +/// let zdt = TimeZone::fixed(offset).to_zoned(dt)?; +/// assert_eq!(zdt.to_string(), "2025-01-03T17:28:00-05:00[-05:00]"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// One problem with the above code snippet is that it completely ignores if +/// a time zone annotation is present. If it is, it probably makes sense to use +/// it, but "fall back" to a fixed offset time zone if it isn't (which the +/// higher level `Zoned` parsing function won't do for you): +/// +/// ``` +/// use jiff::{fmt::temporal::Pieces, tz::TimeZone}; +/// +/// let timestamp = "2025-01-02T15:13-05"; +/// +/// let pieces = Pieces::parse(timestamp)?; +/// let time = pieces.time().unwrap_or_else(jiff::civil::Time::midnight); +/// let dt = pieces.date().to_datetime(time); +/// let tz = match pieces.to_time_zone()? { +/// Some(tz) => tz, +/// None => { +/// let Some(offset) = pieces.to_numeric_offset() else { +/// let msg = format!( +/// "timestamp `{timestamp}` has no time zone \ +/// or offset, and thus cannot be parsed into \ +/// an instant", +/// ); +/// return Err(msg.into()); +/// }; +/// TimeZone::fixed(offset) +/// } +/// }; +/// // We don't bother with offset conflict resolution. And note that +/// // this uses automatic "compatible" disambiguation in the case of +/// // discontinuities. Of course, this is all moot if `TimeZone` is +/// // fixed. The above code handles the case where it isn't! +/// let zdt = tz.to_zoned(dt)?; +/// assert_eq!(zdt.to_string(), "2025-01-02T15:13:00-05:00[-05:00]"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// This is mostly the same as above, but if an annotation is present, we use +/// a `TimeZone` derived from that over the offset present. +/// +/// However, this still doesn't quite capture what happens when parsing into a +/// `Zoned` value. In particular, parsing into a `Zoned` is _also_ doing offset +/// conflict resolution for you. An offset conflict occurs when there is a +/// mismatch between the offset in an RFC 3339 timestamp and the time zone in +/// an RFC 9557 time zone annotation. +/// +/// For example, `2024-06-14T17:30-05[America/New_York]` has a mismatch +/// since the date is in daylight saving time, but the offset, `-05`, is the +/// offset for standard time in `America/New_York`. If this datetime were +/// fed to the above code, then the `-05` offset would be completely ignored +/// and `America/New_York` would resolve the datetime based on its rules. In +/// this case, you'd get `2024-06-14T17:30-04`, which is a different instant +/// than the original datetime! +/// +/// You can either implement your own conflict resolution or use +/// [`tz::OffsetConflict`](crate::tz::OffsetConflict) to do it for you. +/// +/// ``` +/// use jiff::{fmt::temporal::Pieces, tz::{OffsetConflict, TimeZone}}; +/// +/// let timestamp = "2024-06-14T17:30-05[America/New_York]"; +/// // The default for conflict resolution when parsing into a `Zoned` is +/// // actually `Reject`, but we use `AlwaysOffset` here to show a different +/// // strategy. You'll want to pick the conflict resolution that suits your +/// // needs. The `Reject` strategy is what you should pick if you aren't +/// // sure. +/// let conflict_resolution = OffsetConflict::AlwaysOffset; +/// +/// let pieces = Pieces::parse(timestamp)?; +/// let time = pieces.time().unwrap_or_else(jiff::civil::Time::midnight); +/// let dt = pieces.date().to_datetime(time); +/// let ambiguous_zdt = match pieces.to_time_zone()? { +/// Some(tz) => { +/// match pieces.to_numeric_offset() { +/// None => tz.into_ambiguous_zoned(dt), +/// Some(offset) => { +/// conflict_resolution.resolve(dt, offset, tz)? +/// } +/// } +/// } +/// None => { +/// let Some(offset) = pieces.to_numeric_offset() else { +/// let msg = format!( +/// "timestamp `{timestamp}` has no time zone \ +/// or offset, and thus cannot be parsed into \ +/// an instant", +/// ); +/// return Err(msg.into()); +/// }; +/// // Won't even be ambiguous, but gets us the same +/// // type as the branch above. +/// TimeZone::fixed(offset).into_ambiguous_zoned(dt) +/// } +/// }; +/// // We do compatible disambiguation here like we do in the previous +/// // examples, but you could choose any strategy. As with offset conflict +/// // resolution, if you aren't sure what to pick, a safe choice here would +/// // be `ambiguous_zdt.unambiguous()`, which will return an error if the +/// // datetime is ambiguous in any way. Then, if you ever hit an error, you +/// // can examine the case to see if it should be handled in a different way. +/// let zdt = ambiguous_zdt.compatible()?; +/// // Notice that we now have a different civil time and offset, but the +/// // instant it corresponds to is the same as the one we started with. +/// assert_eq!(zdt.to_string(), "2024-06-14T18:30:00-04:00[America/New_York]"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// The above has effectively completely rebuilt the higher level `Zoned` +/// parsing routine, but with a fallback to a fixed time zone when a time zone +/// annotation is not present. +/// +/// # Case study: inferring the time zone of RFC 3339 timestamps +/// +/// As [one real world use case details][infer-time-zone], it might be +/// desirable to try and infer the time zone of RFC 3339 timestamps with +/// varying offsets. This might be applicable when: +/// +/// * You have out-of-band information, possibly contextual, that indicates +/// the timestamps have to come from a fixed set of time zones. +/// * The time zones have different standard offsets. +/// * You have a specific desire or need to use a [`Zoned`] value for its +/// ergonomics and time zone aware handling. After all, in this case, you +/// believe the timestamps to actually be generated from a specific time zone, +/// but the interchange format doesn't support carrying that information. Or +/// the source data simply omits it. +/// +/// In other words, you might be trying to make the best of a bad situation. +/// +/// A `Pieces` can help you accomplish this because it gives you access to each +/// component of a parsed datetime, and thus lets you implement arbitrary logic +/// for how to translate that into a `Zoned`. In this case, there is +/// contextual information that Jiff can't possibly know about. +/// +/// The general approach we take here is to make use of +/// [`tz::OffsetConflict`](crate::tz::OffsetConflict) to query whether a +/// timestamp has a fixed offset compatible with a particular time zone. And if +/// so, we can _probably_ assume it comes from that time zone. One hitch is +/// that it's possible for the timestamp to be valid for multiple time zones, +/// so we check that as well. +/// +/// In the use case linked above, we have fixed offset timestamps from +/// `America/Chicago` and `America/New_York`. So let's try implementing the +/// above strategy. Note that we assume our inputs are RFC 3339 fixed offset +/// timestamps and error otherwise. This is just to keep things simple. To +/// handle data that is more varied, see the previous case study where we +/// respect a time zone annotation if it's present, and fall back to a fixed +/// offset time zone if it isn't. +/// +/// ``` +/// use jiff::{fmt::temporal::Pieces, tz::{OffsetConflict, TimeZone}, Zoned}; +/// +/// // The time zones we're allowed to choose from. +/// let tzs = &[ +/// TimeZone::get("America/New_York")?, +/// TimeZone::get("America/Chicago")?, +/// ]; +/// +/// // Here's our data that lacks time zones. The task is to assign a time zone +/// // from `tzs` to each below and convert it to a `Zoned`. If we fail on any +/// // one, then we substitute `None`. +/// let data = &[ +/// "2024-01-13T10:33-05", +/// "2024-01-25T12:15-06", +/// "2024-03-10T02:30-05", +/// "2024-06-08T14:01-05", +/// "2024-06-12T11:46-04", +/// "2024-11-03T01:30-05", +/// ]; +/// // Our answers. +/// let mut zdts: Vec> = vec![]; +/// for string in data { +/// // Parse and gather up the data that we can from the input. +/// // In this case, that's a civil datetime and an offset from UTC. +/// let pieces = Pieces::parse(string)?; +/// let time = pieces.time().unwrap_or_else(jiff::civil::Time::midnight); +/// let dt = pieces.date().to_datetime(time); +/// let Some(offset) = pieces.to_numeric_offset() else { +/// // A robust implementation should use a TZ annotation if present. +/// return Err("missing offset".into()); +/// }; +/// // Now collect all time zones that are valid for this timestamp. +/// let mut candidates = vec![]; +/// for tz in tzs { +/// let result = OffsetConflict::Reject.resolve(dt, offset, tz.clone()); +/// // The parsed offset isn't valid for this time zone, so reject it. +/// let Ok(ambiguous_zdt) = result else { continue }; +/// // This can never fail because we used the "reject" conflict +/// // resolution strategy. It will never return an ambiguous +/// // `Zoned` since we always have a valid offset that does +/// // disambiguation for us. +/// let zdt = ambiguous_zdt.unambiguous().unwrap(); +/// candidates.push(zdt); +/// } +/// if candidates.len() == 1 { +/// zdts.push(Some(candidates.pop().unwrap())); +/// } else { +/// zdts.push(None); +/// } +/// } +/// assert_eq!(zdts, vec![ +/// Some("2024-01-13T10:33-05[America/New_York]".parse()?), +/// Some("2024-01-25T12:15-06[America/Chicago]".parse()?), +/// // Failed because the clock time falls in a gap in the +/// // transition to daylight saving time, and it could be +/// // valid for either America/New_York or America/Chicago. +/// None, +/// Some("2024-06-08T14:01-05[America/Chicago]".parse()?), +/// Some("2024-06-12T11:46-04[America/New_York]".parse()?), +/// // Failed because the clock time falls in a fold in the +/// // transition out of daylight saving time, and it could be +/// // valid for either America/New_York or America/Chicago. +/// None, +/// ]); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// The one hitch here is that if the time zones are close to each +/// geographically and both have daylight saving time, then there are some +/// RFC 3339 timestamps that are truly ambiguous. For example, +/// `2024-11-03T01:30-05` is perfectly valid for both `America/New_York` and +/// `America/Chicago`. In this case, there is no way to tell which time zone +/// the timestamp belongs to. It might be reasonable to return an error in +/// this case or omit the timestamp. It depends on what you need to do. +/// +/// With more effort, it would also be possible to optimize the above routine +/// by utilizing [`TimeZone::preceding`] and [`TimeZone::following`] to get +/// the exact boundaries of each time zone transition. Then you could use an +/// offset lookup table for each range to determine the appropriate time zone. +/// +/// [infer-time-zone]: https://github.com/BurntSushi/jiff/discussions/181#discussioncomment-11729435 +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Pieces<'n> { + date: Date, + time: Option