diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8e6c4..d28e1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +# CHANGELOG + +0.1.12 (2024-08-31) +=================== +This release introduces some new minor APIs that support formatting +`Timestamp` values as RFC 3339 strings with a specific offset. + +Previously, using the standard formatting routines that Jiff provides, it was +only possible to format a `Timestamp` using Zulu time. For example: + +```rust +use jiff::Timestamp; + +assert_eq!( + Timestamp::UNIX_EPOCH.to_string(), + "1970-01-01T00:00:00Z", +); +``` + +This is fine most use cases, but it can be useful on occasion to format +a `Timestamp` with a specific offset. While this isn't as expressive +as formatting a datetime with a time zone (e.g., with an IANA time +zone identifier), it may be useful in contexts where you just want to +"hint" at what a user's local time is. To that end, there is a new +`Timestamp::display_with_offset` method that makes this possible: + +```rust +use jiff::{tz, Timestamp}; + +assert_eq!( + Timestamp::UNIX_EPOCH.display_with_offset(tz::offset(-5)).to_string(), + "1969-12-31T19:00:00-05:00", +); +``` + +A corresponding API was added to `jiff::fmt::temporal::DateTimePrinter` for +lower level use. + +Moreover, this release also includes new convenience APIs on the Temporal and +RFC 2822 printer types for returning strings. For example, previously, if you +were using the RFC 2822 printer to format a `Timestamp`, you had to do this: + +```rust +use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp}; + +let mut buf = String::new(); +DateTimePrinter::new().print_timestamp(&Timestamp::UNIX_EPOCH, &mut buf).unwrap(); +assert_eq!(buf, "Thu, 1 Jan 1970 00:00:00 -0000"); +``` + +But now you can just do this: + +```rust +use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp}; + +assert_eq!( + DateTimePrinter::new().timestamp_to_string(&Timestamp::UNIX_EPOCH).unwrap(), + "Thu, 1 Jan 1970 00:00:00 -0000", +); +``` + +Enhancements: + +* [#122](https://github.com/BurntSushi/jiff/issues/122): +Support formatting `Timestamp` to an RFC 3339 string with a specific offset. + + 0.1.11 (2024-08-28) =================== This release includes a few small enhancements that have been requested over @@ -10,15 +77,16 @@ or nanoseconds. For example: ```rust use jiff::Timestamp; +#[derive(serde::Serialize, serde::Deserialize)] struct Record { #[serde(with = "jiff::fmt::serde::timestamp::second::required")] timestamp: Timestamp, } let json = r#"{"timestamp":1517644800}"#; -let got: Record = serde_json::from_str(&json)?; -assert_eq!(got.timestamp, Timestamp::from_second(1517644800)?); -assert_eq!(serde_json::to_string(&got)?, json); +let got: Record = serde_json::from_str(&json).unwrap(); +assert_eq!(got.timestamp, Timestamp::from_second(1517644800).unwrap()); +assert_eq!(serde_json::to_string(&got).unwrap(), json); ``` If you need to support optional timestamps via `Option`, then use diff --git a/DESIGN.md b/DESIGN.md index a3fa86b..82ea2db 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -263,22 +263,50 @@ Here's a list. More may be added in the future: [alt1]: https://github.com/BurntSushi/jiff/issues/63 -## Why is there one duration type instead of two? - -In large part, this design decision came from [Temporal], which also [uses -only one duration type][temporal-one-duration]. In Temporal, the duration -type is aptly named `Duration`, where as in Jiff, the duration type is -called `Span`. This is to distinguish it from the standard library type, -`std::time::Duration`, which is similar but subtly different. - -The main alternative design, found in [`java.time`] and [NodaTime], is to -have _two_ duration types: one duration type for describing calendar units -and another duration type for describing "absolute" time. The key distinction -between these types is that of uniform units. An absolute time duration only -uses units that are uniform. That is, units that always correspond to the same -amount of elapsed time, regardless of context. Conversely, a duration type -with calendar units uses non-uniform units. For example, 1 month starting from -`April 1` is shorter (30 days) than 1 month starting from `May 1` (31 days). +## Why are there two duration types? + +The two duration types provided by Jiff are `Span` and `SignedDuration`. A +`SignedDuration` is effectively identical to a `std::time::Duration`, but it's +signed instead of unsigned. A `Span` is also a duration type, but is likely +different than most other duration types you've used before. + +While a `SignedDuration` can be thought of as a single integer corresponding +to the number of nanoseconds between two points in time, a `Span` is a +collection of individual unit values that combine to represent the difference +between two point in time. Stated more concretely, while the spans `2 +hours` and `120 minutes` both correspond to the same duration of time, when +represented as a Jiff `Span`, they correspond to two distinct values in +memory. This is something that is fundamentally not expressible by a type +like `SignedDuration`, where `2 hours` and `120 minutes` are completely +indistinguishable. + +One of the key advantages of a `Span` is that it can represent units of +non-uniform length. For example, not every month has the same number of days, +but a `Span` can still represent units of months because it tracks the values +of each unit independently. For example, Jiff is smart enough to know that the +difference between `2024-03-01` and `2024-04-01` is the same number of months +as `2024-04-01` and `2024-05-01`, even though the number of days is different: + +```rust +use jiff::{civil::date, ToSpan, Unit}; + +fn main() -> anyhow::Result<()> { + let date1 = date(2024, 3, 1); + let date2 = date(2024, 4, 1); + let date3 = date(2024, 5, 1); + + // When computing durations between `Date` values, + // the spans default to days. + assert_eq!(date1.until(date2)?, 31.days()); + assert_eq!(date2.until(date3)?, 30.days()); + + // But we can request bigger units! + assert_eq!(date1.until((Unit::Month, date2))?, 1.month()); + assert_eq!(date2.until((Unit::Month, date3))?, 1.month()); + + Ok(()) +} +``` While most folks are very in tune with the fact that years and months have non-uniform length, a less obvious truth is that days themselves also have @@ -287,47 +315,36 @@ non-uniform length in the presence of time zones. For example, `2024-03-10` in time, creating a gap in time), while `2024-11-03` was 25 hours long (the region left daylight saving time, creating a fold in time). Being unaware of this corner case leads to folks assuming that "1 day" and "24 hours" are _always_ -exactly equivalent. But they aren't. - -The idea then is not just to separate durations into two distinct types, but to -provide _distinct operations_ that clearly delineate absolute time (where, -typically, days, if expressible, are always 24 hours in length) from -calendar time (where days might be shorter or longer than 24 hours). - -While I do acknowledge that delineating calendar from absolute span operations -can make some cases clearer, my belief (before Jiff's initial release) is -that a single span type that combines all units will ultimately be simpler -and less error prone. In particular, one crucial thing to note here is the -existence of the `Zoned` data type. That is, whenever you use the units "days" -with a `Zoned` type, it should always do the right thing. And if you need to -do arithmetic that ignores DST, you can use `Zoned::datetime` to get civil -time and perform DST-ignorant arithmetic (where days are always 24 hours). In -a sense, the distinct calendar and absolute durations are still captured by -Jiff's API, but are done via the datetime types instead of the span types. - -As the [Temporal GitHub issue on this topic discusses][temporal-one-duration], -there are some significant advantages to a single span type. In my own words: +exactly equivalent. But they aren't. The design of Jiff leans into this and +ensures that so long as you're using `Span` to encode a concept of days and are +doing arithmetic with it on `Zoned` values, then you can never get it wrong. +Jiff will always take time zones into account when dealing with units of days +or bigger. + +The design of `Span` comes from [Temporal], which [uses only one duration +type][temporal-one-duration]. From that issue, there are some significant +advantages to using a `Span`. In my own words: * It more closely lines up with ISO 8601 durations, which themselves combine calendar and clock units. -* A single type makes it very easy to move between `5 years 2 months` and +* With a `Span`, it is very easy to move between `5 years 2 months` and the number of hours in that same span. * Jiff's `Span` type specifically represents each unit as distinct from the -others. In contrast, most absolute duration types (like `std::time::Duration`), -are "just" a 96-bit integer number of nanoseconds. This means that, for -example, `1 hour 30 minutes` is impossible to differentiate from `90 minutes`. -But depending on the use case, you might want one or the other. Jiff's -`Span` design (copied from Temporal) enables users to express durations in -whatever units they want. And this expression can be manipulated via APIs like -`Span::round` in intuitive ways. -* APIs like `Zoned::since` would not be as simple as they are today. If we -have multiple duration types, then we'd need multiple distinct operations to -compute those durations between datetimes. Or at least, one operation that -returns each of the different types of duration. - -Ultimately, I think it is likely that Jiff will end up growing support for -[absolute durations][github-issue-duration], but likely for reasons related -to performance and better integration with standard library types. +others. In contrast, most absolute duration types (like `std::time::Duration` +and Jiff's own `SignedDuration`), are "just" a 96-bit integer number of +nanoseconds. This means that, for example, `1 hour 30 minutes` is impossible to +differentiate from `90 minutes`. But depending on the use case, you might want +one or the other. Jiff's `Span` design (copied from Temporal) enables users +to express durations in whatever units they want. And this expression can be +manipulated via APIs like `Span::round` in intuitive ways. + +A `SignedDuration` is still useful in some respects. For example, when you +need tighter integration with the standard library's `std::time::Duration` +(since a `SignedDuration` is the same, but just signed), or when you need +better performance than what `Span` gives you. In particular, since a `Span` +keeps track of the values for each individual unit, it is a much heavier type +than a `SignedDuration`. It uses up more stack space and also required more +computation to do arithmetic with it. ## Why isn't there a `TimeZone` trait? diff --git a/src/fmt/rfc2822.rs b/src/fmt/rfc2822.rs index 43402d8..1fc9de9 100644 --- a/src/fmt/rfc2822.rs +++ b/src/fmt/rfc2822.rs @@ -105,6 +105,7 @@ pub(crate) static DEFAULT_DATETIME_PRINTER: DateTimePrinter = /// /// # Ok::<(), Box>(()) /// ``` +#[inline] pub fn to_string(zdt: &Zoned) -> Result { let mut buf = String::new(); DEFAULT_DATETIME_PRINTER.print_zoned(zdt, &mut buf)?; @@ -168,6 +169,7 @@ pub fn to_string(zdt: &Zoned) -> Result { /// /// # Ok::<(), Box>(()) /// ``` +#[inline] pub fn parse(string: &str) -> Result { DEFAULT_DATETIME_PARSER.parse_zoned(string) } @@ -222,6 +224,7 @@ pub struct DateTimeParser { impl DateTimeParser { /// Create a new RFC 2822 datetime parser with the default configuration. + #[inline] pub const fn new() -> DateTimeParser { DateTimeParser { relaxed_weekday: false } } @@ -259,6 +262,7 @@ impl DateTimeParser { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn relaxed_weekday(self, yes: bool) -> DateTimeParser { DateTimeParser { relaxed_weekday: yes, ..self } } @@ -1090,10 +1094,106 @@ pub struct DateTimePrinter { impl DateTimePrinter { /// Create a new RFC 2822 datetime printer with the default configuration. + #[inline] pub const fn new() -> DateTimePrinter { DateTimePrinter { _private: () } } + /// Format a `Zoned` datetime into a string. + /// + /// This never emits `-0000` as the offset in the RFC 2822 format. If you + /// desire a `-0000` offset, use [`DateTimePrinter::print_timestamp`] via + /// [`Zoned::timestamp`]. + /// + /// Moreover, since RFC 2822 does not support fractional seconds, this + /// routine prints the zoned datetime as if truncating any fractional + /// seconds. + /// + /// This is a convenience routine for [`DateTimePrinter::print_zoned`] + /// with a `String`. + /// + /// # Warning + /// + /// The RFC 2822 format only supports writing a precise instant in time + /// expressed via a time zone offset. It does *not* support serializing + /// the time zone itself. This means that if you format a zoned datetime + /// in a time zone like `America/New_York` and then deserialize it, the + /// zoned datetime you get back will be a "fixed offset" zoned datetime. + /// This in turn means it will not perform daylight saving time safe + /// arithmetic. + /// + /// Basically, you should use the RFC 2822 format if it's required (for + /// example, when dealing with HTTP). But you should not choose it as a + /// general interchange format for new applications. + /// + /// # Errors + /// + /// This can return an error if the year corresponding to this timestamp + /// cannot be represented in the RFC 2822 format. For example, a negative + /// year. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::date, fmt::rfc2822::DateTimePrinter}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).intz("America/New_York")?; + /// assert_eq!( + /// PRINTER.zoned_to_string(&zdt)?, + /// "Sat, 15 Jun 2024 07:00:00 -0400", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn zoned_to_string(&self, zdt: &Zoned) -> Result { + let mut buf = String::with_capacity(4); + self.print_zoned(zdt, &mut buf)?; + Ok(buf) + } + + /// Format a `Timestamp` datetime into a string. + /// + /// This always emits `-0000` as the offset in the RFC 2822 format. If you + /// desire a `+0000` offset, use [`DateTimePrinter::print_zoned`] with a + /// zoned datetime with [`TimeZone::UTC`]. + /// + /// Moreover, since RFC 2822 does not support fractional seconds, this + /// routine prints the timestamp as if truncating any fractional seconds. + /// + /// This is a convenience routine for [`DateTimePrinter::print_timestamp`] + /// with a `String`. + /// + /// # Errors + /// + /// This returns an error if the year corresponding to this + /// timestamp cannot be represented in the RFC 2822 format. For example, a + /// negative year. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp}; + /// + /// let timestamp = Timestamp::from_second(1) + /// .expect("one second after Unix epoch is always valid"); + /// assert_eq!( + /// DateTimePrinter::new().timestamp_to_string(×tamp)?, + /// "Thu, 1 Jan 1970 00:00:01 -0000", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn timestamp_to_string( + &self, + timestamp: &Timestamp, + ) -> Result { + let mut buf = String::with_capacity(4); + self.print_timestamp(timestamp, &mut buf)?; + Ok(buf) + } + /// Print a `Zoned` datetime to the given writer. /// /// This never emits `-0000` as the offset in the RFC 2822 format. If you diff --git a/src/fmt/temporal/mod.rs b/src/fmt/temporal/mod.rs index 59552dd..c50a428 100644 --- a/src/fmt/temporal/mod.rs +++ b/src/fmt/temporal/mod.rs @@ -163,12 +163,14 @@ There is some more [background on Temporal's format] available. [background on Temporal's format]: https://github.com/tc39/proposal-temporal/issues/2843 */ +use alloc::string::String; + use crate::{ civil, error::Error, fmt::Write, span::Span, - tz::{Disambiguation, OffsetConflict, TimeZoneDatabase}, + tz::{Disambiguation, Offset, OffsetConflict, TimeZoneDatabase}, SignedDuration, Timestamp, Zoned, }; @@ -244,6 +246,7 @@ pub struct DateTimeParser { impl DateTimeParser { /// Create a new Temporal datetime parser with the default configuration. + #[inline] pub const fn new() -> DateTimeParser { DateTimeParser { p: parser::DateTimeParser::new(), @@ -283,6 +286,7 @@ impl DateTimeParser { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn offset_conflict( self, strategy: OffsetConflict, @@ -363,6 +367,7 @@ impl DateTimeParser { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn disambiguation( self, strategy: Disambiguation, @@ -849,6 +854,7 @@ impl DateTimePrinter { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn lowercase(mut self, yes: bool) -> DateTimePrinter { self.p = self.p.lowercase(yes); self @@ -880,6 +886,7 @@ impl DateTimePrinter { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn separator(mut self, ascii_char: u8) -> DateTimePrinter { self.p = self.p.separator(ascii_char); self @@ -941,6 +948,7 @@ impl DateTimePrinter { /// /// # Ok::<(), Box>(()) /// ``` + #[inline] pub const fn precision( mut self, precision: Option, @@ -949,6 +957,198 @@ impl DateTimePrinter { self } + /// Format a `Zoned` datetime into a string. + /// + /// This is a convenience routine for [`DateTimePrinter::print_zoned`] with + /// a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::date, fmt::temporal::DateTimePrinter}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).intz("America/New_York")?; + /// assert_eq!( + /// PRINTER.zoned_to_string(&zdt), + /// "2024-06-15T07:00:00-04:00[America/New_York]", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn zoned_to_string(&self, zdt: &Zoned) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_zoned(zdt, &mut buf).unwrap(); + buf + } + + /// Format a `Timestamp` datetime into a string. + /// + /// This will always return an RFC 3339 compatible string with a `Z` or + /// Zulu offset. Zulu is chosen in accordance with RFC 9557's update to + /// RFC 3339 that establishes the `-00:00` offset as equivalent to Zulu: + /// + /// > If the time in UTC is known, but the offset to local time is + /// > unknown, this can be represented with an offset of "Z". (The + /// > original version of this specification provided -00:00 for this + /// > purpose, which is not allowed by ISO8601:2000 and therefore is + /// > less interoperable; Section 3.3 of RFC5322 describes a related + /// > convention for email, which does not have this problem). This + /// > differs semantically from an offset of +00:00, which implies that + /// > UTC is the preferred reference point for the specified time. + /// + /// In other words, both Zulu time and `-00:00` mean "the time in UTC is + /// known, but the offset to local time is unknown." + /// + /// If you need to format an RFC 3339 timestamp with a specific offset, + /// use [`DateTimePrinter::timestamp_with_offset_to_string`]. + /// + /// This is a convenience routine for [`DateTimePrinter::print_timestamp`] + /// with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::temporal::DateTimePrinter, Timestamp}; + /// + /// let timestamp = Timestamp::new(0, 1) + /// .expect("one nanosecond after Unix epoch is always valid"); + /// assert_eq!( + /// DateTimePrinter::new().timestamp_to_string(×tamp), + /// "1970-01-01T00:00:00.000000001Z", + /// ); + /// ``` + pub fn timestamp_to_string(&self, timestamp: &Timestamp) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_timestamp(timestamp, &mut buf).unwrap(); + buf + } + + /// Format a `Timestamp` datetime into a string with the given offset. + /// + /// This will always return an RFC 3339 compatible string with an offset. + /// + /// This will never use either `Z` (for Zulu time) or `-00:00` as an + /// offset. This is because Zulu time (and `-00:00`) mean "the time in UTC + /// is known, but the offset to local time is unknown." Since this routine + /// accepts an explicit offset, the offset is known. For example, + /// `Offset::UTC` will be formatted as `+00:00`. + /// + /// To format an RFC 3339 string in Zulu time, use + /// [`DateTimePrinter::timestamp_to_string`]. + /// + /// This is a convenience routine for + /// [`DateTimePrinter::print_timestamp_with_offset`] with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::temporal::DateTimePrinter, tz, Timestamp}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let timestamp = Timestamp::new(0, 1) + /// .expect("one nanosecond after Unix epoch is always valid"); + /// assert_eq!( + /// PRINTER.timestamp_with_offset_to_string(×tamp, tz::offset(-5)), + /// "1969-12-31T19:00:00.000000001-05:00", + /// ); + /// ``` + /// + /// # Example: `Offset::UTC` formats as `+00:00` + /// + /// ``` + /// use jiff::{fmt::temporal::DateTimePrinter, tz::Offset, Timestamp}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let timestamp = Timestamp::new(0, 1) + /// .expect("one nanosecond after Unix epoch is always valid"); + /// assert_eq!( + /// PRINTER.timestamp_with_offset_to_string(×tamp, Offset::UTC), + /// "1970-01-01T00:00:00.000000001+00:00", + /// ); + /// ``` + pub fn timestamp_with_offset_to_string( + &self, + timestamp: &Timestamp, + offset: Offset, + ) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_timestamp_with_offset(timestamp, offset, &mut buf).unwrap(); + buf + } + + /// Format a `civil::DateTime` into a string. + /// + /// This is a convenience routine for [`DateTimePrinter::print_datetime`] + /// with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::date, fmt::temporal::DateTimePrinter}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let dt = date(2024, 6, 15).at(7, 0, 0, 0); + /// assert_eq!(PRINTER.datetime_to_string(&dt), "2024-06-15T07:00:00"); + /// ``` + pub fn datetime_to_string(&self, dt: &civil::DateTime) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_datetime(dt, &mut buf).unwrap(); + buf + } + + /// Format a `civil::Date` into a string. + /// + /// This is a convenience routine for [`DateTimePrinter::print_date`] + /// with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::date, fmt::temporal::DateTimePrinter}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let d = date(2024, 6, 15); + /// assert_eq!(PRINTER.date_to_string(&d), "2024-06-15"); + /// ``` + pub fn date_to_string(&self, date: &civil::Date) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_date(date, &mut buf).unwrap(); + buf + } + + /// Format a `civil::Time` into a string. + /// + /// This is a convenience routine for [`DateTimePrinter::print_time`] + /// with a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::time, fmt::temporal::DateTimePrinter}; + /// + /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); + /// + /// let t = time(7, 0, 0, 0); + /// assert_eq!(PRINTER.time_to_string(&t), "07:00:00"); + /// ``` + pub fn time_to_string(&self, time: &civil::Time) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_time(time, &mut buf).unwrap(); + buf + } + /// Print a `Zoned` datetime to the given writer. /// /// # Errors @@ -984,6 +1184,25 @@ impl DateTimePrinter { /// Print a `Timestamp` datetime to the given writer. /// + /// This will always write an RFC 3339 compatible string with a `Z` or + /// Zulu offset. Zulu is chosen in accordance with RFC 9557's update to + /// RFC 3339 that establishes the `-00:00` offset as equivalent to Zulu: + /// + /// > If the time in UTC is known, but the offset to local time is + /// > unknown, this can be represented with an offset of "Z". (The + /// > original version of this specification provided -00:00 for this + /// > purpose, which is not allowed by ISO8601:2000 and therefore is + /// > less interoperable; Section 3.3 of RFC5322 describes a related + /// > convention for email, which does not have this problem). This + /// > differs semantically from an offset of +00:00, which implies that + /// > UTC is the preferred reference point for the specified time. + /// + /// In other words, both Zulu time and `-00:00` mean "the time in UTC is + /// known, but the offset to local time is unknown." + /// + /// If you need to write an RFC 3339 timestamp with a specific offset, + /// use [`DateTimePrinter::print_timestamp_with_offset`]. + /// /// # Errors /// /// This only returns an error when writing to the given [`Write`] @@ -1011,7 +1230,75 @@ impl DateTimePrinter { timestamp: &Timestamp, wtr: W, ) -> Result<(), Error> { - self.p.print_timestamp(timestamp, wtr) + self.p.print_timestamp(timestamp, None, wtr) + } + + /// Print a `Timestamp` datetime to the given writer with the given offset. + /// + /// This will always write an RFC 3339 compatible string with an offset. + /// + /// This will never write either `Z` (for Zulu time) or `-00:00` as an + /// offset. This is because Zulu time (and `-00:00`) mean "the time in UTC + /// is known, but the offset to local time is unknown." Since this routine + /// accepts an explicit offset, the offset is known. For example, + /// `Offset::UTC` will be formatted as `+00:00`. + /// + /// To write an RFC 3339 string in Zulu time, use + /// [`DateTimePrinter::print_timestamp`]. + /// + /// # 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::{fmt::temporal::DateTimePrinter, tz, Timestamp}; + /// + /// let timestamp = Timestamp::new(0, 1) + /// .expect("one nanosecond after Unix epoch is always valid"); + /// + /// let mut buf = String::new(); + /// // Printing to a `String` can never fail. + /// DateTimePrinter::new().print_timestamp_with_offset( + /// ×tamp, + /// tz::offset(-5), + /// &mut buf, + /// ).unwrap(); + /// assert_eq!(buf, "1969-12-31T19:00:00.000000001-05:00"); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Example: `Offset::UTC` formats as `+00:00` + /// + /// ``` + /// use jiff::{fmt::temporal::DateTimePrinter, tz::Offset, Timestamp}; + /// + /// let timestamp = Timestamp::new(0, 1) + /// .expect("one nanosecond after Unix epoch is always valid"); + /// + /// let mut buf = String::new(); + /// // Printing to a `String` can never fail. + /// DateTimePrinter::new().print_timestamp_with_offset( + /// ×tamp, + /// Offset::UTC, // equivalent to `Offset::from_hours(0)` + /// &mut buf, + /// ).unwrap(); + /// assert_eq!(buf, "1970-01-01T00:00:00.000000001+00:00"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn print_timestamp_with_offset( + &self, + timestamp: &Timestamp, + offset: Offset, + wtr: W, + ) -> Result<(), Error> { + self.p.print_timestamp(timestamp, Some(offset), wtr) } /// Print a `civil::DateTime` to the given writer. @@ -1030,11 +1317,11 @@ impl DateTimePrinter { /// /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); /// - /// let dt = date(2024, 6, 15).at(7, 0, 0, 0); + /// let d = date(2024, 6, 15).at(7, 0, 0, 0); /// /// let mut buf = String::new(); /// // Printing to a `String` can never fail. - /// PRINTER.print_datetime(&dt, &mut buf).unwrap(); + /// PRINTER.print_datetime(&d, &mut buf).unwrap(); /// assert_eq!(buf, "2024-06-15T07:00:00"); /// /// # Ok::<(), Box>(()) @@ -1063,11 +1350,11 @@ impl DateTimePrinter { /// /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); /// - /// let dt = date(2024, 6, 15); + /// let d = date(2024, 6, 15); /// /// let mut buf = String::new(); /// // Printing to a `String` can never fail. - /// PRINTER.print_date(&dt, &mut buf).unwrap(); + /// PRINTER.print_date(&d, &mut buf).unwrap(); /// assert_eq!(buf, "2024-06-15"); /// /// # Ok::<(), Box>(()) @@ -1096,11 +1383,11 @@ impl DateTimePrinter { /// /// const PRINTER: DateTimePrinter = DateTimePrinter::new(); /// - /// let dt = time(7, 0, 0, 0); + /// let t = time(7, 0, 0, 0); /// /// let mut buf = String::new(); /// // Printing to a `String` can never fail. - /// PRINTER.print_time(&dt, &mut buf).unwrap(); + /// PRINTER.print_time(&t, &mut buf).unwrap(); /// assert_eq!(buf, "07:00:00"); /// /// # Ok::<(), Box>(()) @@ -1144,6 +1431,7 @@ pub struct SpanParser { impl SpanParser { /// Create a new Temporal datetime printer with the default configuration. + #[inline] pub const fn new() -> SpanParser { SpanParser { p: parser::SpanParser::new() } } @@ -1302,10 +1590,63 @@ pub struct SpanPrinter { impl SpanPrinter { /// Create a new Temporal span printer with the default configuration. + #[inline] pub const fn new() -> SpanPrinter { SpanPrinter { p: printer::SpanPrinter::new() } } + /// Format a `Span` into a string. + /// + /// This is a convenience routine for [`SpanPrinter::print_span`] with + /// a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::temporal::SpanPrinter, ToSpan}; + /// + /// const PRINTER: SpanPrinter = SpanPrinter::new(); + /// + /// let span = 3.years().months(5); + /// assert_eq!(PRINTER.span_to_string(&span), "P3y5m"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn span_to_string(&self, span: &Span) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_span(span, &mut buf).unwrap(); + buf + } + + /// Format a `SignedDuration` into a string. + /// + /// This balances the units of the duration up to at most hours + /// automatically. + /// + /// This is a convenience routine for [`SpanPrinter::print_duration`] with + /// a `String`. + /// + /// # Example + /// + /// ``` + /// use jiff::{fmt::temporal::SpanPrinter, SignedDuration}; + /// + /// 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"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn duration_to_string(&self, duration: &SignedDuration) -> String { + let mut buf = String::with_capacity(4); + // OK because writing to `String` never fails. + self.print_duration(duration, &mut buf).unwrap(); + buf + } + /// Print a `Span` to the given writer. /// /// # Errors diff --git a/src/fmt/temporal/printer.rs b/src/fmt/temporal/printer.rs index 0822256..a8f17b4 100644 --- a/src/fmt/temporal/printer.rs +++ b/src/fmt/temporal/printer.rs @@ -63,11 +63,18 @@ impl DateTimePrinter { pub(super) fn print_timestamp( &self, timestamp: &Timestamp, + offset: Option, mut wtr: W, ) -> Result<(), Error> { - let dt = TimeZone::UTC.to_datetime(*timestamp); + let Some(offset) = offset else { + let dt = TimeZone::UTC.to_datetime(*timestamp); + self.print_datetime(&dt, &mut wtr)?; + self.print_zulu(&mut wtr)?; + return Ok(()); + }; + let dt = offset.to_datetime(*timestamp); self.print_datetime(&dt, &mut wtr)?; - self.print_zulu(&mut wtr)?; + self.print_offset(&offset, &mut wtr)?; Ok(()) } @@ -427,7 +434,7 @@ mod tests { let zoned: Zoned = dt.intz("America/New_York").unwrap(); let mut buf = String::new(); DateTimePrinter::new() - .print_timestamp(&zoned.timestamp(), &mut buf) + .print_timestamp(&zoned.timestamp(), None, &mut buf) .unwrap(); assert_eq!(buf, "2024-03-10T09:34:45Z"); @@ -435,7 +442,7 @@ mod tests { let zoned: Zoned = dt.intz("America/New_York").unwrap(); let mut buf = String::new(); DateTimePrinter::new() - .print_timestamp(&zoned.timestamp(), &mut buf) + .print_timestamp(&zoned.timestamp(), None, &mut buf) .unwrap(); assert_eq!(buf, "-002024-03-10T10:30:47Z"); } diff --git a/src/lib.rs b/src/lib.rs index a1d19b0..b058186 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,7 @@ documentation: * [Comparison with other Rust datetime crates](crate::_documentation::comparison) * [The API design rationale for Jiff](crate::_documentation::design) * [Platform support](crate::_documentation::platform) +* [CHANGELOG](crate::_documentation::changelog) # Features @@ -688,8 +689,8 @@ pub use crate::{ SpanTotal, ToSpan, Unit, }, timestamp::{ - Timestamp, TimestampArithmetic, TimestampDifference, TimestampRound, - TimestampSeries, + Timestamp, TimestampArithmetic, TimestampDifference, + TimestampDisplayWithOffset, TimestampRound, TimestampSeries, }, util::round::mode::RoundMode, zoned::{Zoned, ZonedArithmetic, ZonedDifference, ZonedRound, ZonedWith}, @@ -719,6 +720,8 @@ pub mod _documentation { pub mod design {} #[doc = include_str!("../PLATFORM.md")] pub mod platform {} + #[doc = include_str!("../CHANGELOG.md")] + pub mod changelog {} } #[cfg(test)] diff --git a/src/timestamp.rs b/src/timestamp.rs index 922a8d0..5df3d15 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -7,7 +7,7 @@ use crate::{ self, temporal::{self, DEFAULT_DATETIME_PARSER}, }, - tz::TimeZone, + tz::{Offset, TimeZone}, util::{ rangeint::{RFrom, RInto}, round::increment, @@ -1874,7 +1874,7 @@ impl Timestamp { } } -/// Parsing and formatting using a "printf"-style API. +/// Parsing and formatting APIs. impl Timestamp { /// Parses a timestamp (expressed as broken down time) in `input` matching /// the given `format`. @@ -1952,6 +1952,40 @@ impl Timestamp { ) -> fmt::strtime::Display<'f> { fmt::strtime::Display { fmt: format.as_ref(), tm: (*self).into() } } + + /// Format a `Timestamp` datetime into a string with the given offset. + /// + /// This will format to an RFC 3339 compatible string with an offset. + /// + /// This will never use either `Z` (for Zulu time) or `-00:00` as an + /// offset. This is because Zulu time (and `-00:00`) mean "the time in UTC + /// is known, but the offset to local time is unknown." Since this routine + /// accepts an explicit offset, the offset is known. For example, + /// `Offset::UTC` will be formatted as `+00:00`. + /// + /// To format an RFC 3339 string in Zulu time, use the default + /// [`std::fmt::Display`] trait implementation on `Timestamp`. + /// + /// # Example + /// + /// ``` + /// use jiff::{tz, Timestamp}; + /// + /// let ts = Timestamp::from_second(1)?; + /// assert_eq!( + /// ts.display_with_offset(tz::offset(-5)).to_string(), + /// "1969-12-31T19:00:01-05:00", + /// ); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn display_with_offset( + &self, + offset: Offset, + ) -> TimestampDisplayWithOffset { + TimestampDisplayWithOffset { timestamp: *self, offset } + } } /// Deprecated APIs on `Timestamp`. @@ -2335,7 +2369,14 @@ impl core::fmt::Debug for Timestamp { /// Converts a `Timestamp` datetime into a RFC 3339 compliant string. /// -/// Options currently supported: +/// Since a `Timestamp` never has an offset associated with it and is always +/// in UTC, the string emitted by this trait implementation uses `Z` for "Zulu" +/// time. The significance of Zulu time is prescribed by RFC 9557 and means +/// that "the time in UTC is known, but the offset to local time is unknown." +/// If you need to emit an RFC 3339 compliant string with a specific offset, +/// then use [`Timestamp::display_with_offset`]. +/// +/// # Forrmatting options supported /// /// * [`std::fmt::Formatter::precision`] can be set to control the precision /// of the fractional second component. @@ -2714,6 +2755,69 @@ impl quickcheck::Arbitrary for Timestamp { } } +/// A type for formatting a [`Timestamp`] with a specific offset. +/// +/// This type is created by the [`Timestamp::display_with_offset`] method. +/// +/// Like the [`std::fmt::Display`] trait implementation for `Timestamp`, this +/// always emits an RFC 3339 compliant string. Unlike `Timestamp`'s `Display` +/// trait implementation, which always uses `Z` or "Zulu" time, this always +/// uses an offfset. +/// +/// # Forrmatting options supported +/// +/// * [`std::fmt::Formatter::precision`] can be set to control the precision +/// of the fractional second component. +/// +/// # Example +/// +/// ``` +/// use jiff::{tz, Timestamp}; +/// +/// let offset = tz::offset(-5); +/// let ts = Timestamp::new(1_123_456_789, 123_000_000)?; +/// assert_eq!( +/// format!("{ts:.6}", ts = ts.display_with_offset(offset)), +/// "2005-08-07T18:19:49.123000-05:00", +/// ); +/// // Precision values greater than 9 are clamped to 9. +/// assert_eq!( +/// format!("{ts:.300}", ts = ts.display_with_offset(offset)), +/// "2005-08-07T18:19:49.123000000-05:00", +/// ); +/// // A precision of 0 implies the entire fractional +/// // component is always truncated. +/// assert_eq!( +/// format!("{ts:.0}", ts = ts.display_with_offset(tz::Offset::UTC)), +/// "2005-08-07T23:19:49+00:00", +/// ); +/// +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct TimestampDisplayWithOffset { + timestamp: Timestamp, + offset: Offset, +} + +impl core::fmt::Display for TimestampDisplayWithOffset { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + use crate::fmt::StdFmtWrite; + + let precision = + f.precision().map(|p| u8::try_from(p).unwrap_or(u8::MAX)); + temporal::DateTimePrinter::new() + .precision(precision) + .print_timestamp_with_offset( + &self.timestamp, + self.offset, + StdFmtWrite(f), + ) + .map_err(|_| core::fmt::Error) + } +} + /// An iterator over periodic timestamps, created by [`Timestamp::series`]. /// /// It is exhausted when the next value would exceed a [`Span`] or