Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fmt: support formatting Timestamp with a specific offset #122

Merged
merged 8 commits into from
Aug 31, 2024
74 changes: 71 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Timestamp>`, then use
Expand Down
121 changes: 69 additions & 52 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down
100 changes: 100 additions & 0 deletions src/fmt/rfc2822.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub(crate) static DEFAULT_DATETIME_PRINTER: DateTimePrinter =
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn to_string(zdt: &Zoned) -> Result<String, Error> {
let mut buf = String::new();
DEFAULT_DATETIME_PRINTER.print_zoned(zdt, &mut buf)?;
Expand Down Expand Up @@ -168,6 +169,7 @@ pub fn to_string(zdt: &Zoned) -> Result<String, Error> {
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub fn parse(string: &str) -> Result<Zoned, Error> {
DEFAULT_DATETIME_PARSER.parse_zoned(string)
}
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -259,6 +262,7 @@ impl DateTimeParser {
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[inline]
pub const fn relaxed_weekday(self, yes: bool) -> DateTimeParser {
DateTimeParser { relaxed_weekday: yes, ..self }
}
Expand Down Expand Up @@ -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<dyn std::error::Error>>(())
/// ```
pub fn zoned_to_string(&self, zdt: &Zoned) -> Result<String, Error> {
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(&timestamp)?,
/// "Thu, 1 Jan 1970 00:00:01 -0000",
/// );
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn timestamp_to_string(
&self,
timestamp: &Timestamp,
) -> Result<String, Error> {
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
Expand Down
Loading