From c203da8c845b4edf30de6591f5d3ceb9aab62550 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Sat, 13 Jul 2024 10:25:58 -0400 Subject: [PATCH] progress --- src/fmt/mod.rs | 1 + src/fmt/rfc2822.rs | 415 ++++++++++++++++++++++++++++++++++++++++ src/fmt/temporal/mod.rs | 2 +- 3 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/fmt/rfc2822.rs diff --git a/src/fmt/mod.rs b/src/fmt/mod.rs index 75faa71..fefa1d2 100644 --- a/src/fmt/mod.rs +++ b/src/fmt/mod.rs @@ -17,6 +17,7 @@ use crate::{ use self::util::{Decimal, DecimalFormatter}; mod offset; +pub mod rfc2822; mod rfc9557; pub mod temporal; mod util; diff --git a/src/fmt/rfc2822.rs b/src/fmt/rfc2822.rs new file mode 100644 index 0000000..ccebc5e --- /dev/null +++ b/src/fmt/rfc2822.rs @@ -0,0 +1,415 @@ +/*! +Support for printing and parsing instants using the [RFC 2822] datetime format. + +RFC 2822 is most commonly found when dealing with HTTP headers and email +messages. + +[RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822 + +# 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. +*/ + +use crate::{ + civil::{DateTime, Weekday}, + error::err, + fmt::{util::DecimalFormatter, Write, WriteExt}, + tz::{Offset, TimeZone}, + Error, Timestamp, Zoned, +}; + +/// A printer for [RFC 2822] datetimes. +/// +/// This printer converts an in memory representation of a precise instant in +/// time to an RFC 2822 formatted string. That is, [`Zoned`] or [`Timestamp`], +/// since all other datetime types in Jiff are inexact. +/// +/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822 +/// +/// # 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. +/// +/// # Example +/// +/// This example shows how to convert a zoned datetime to the RFC 2822 format: +/// +/// ``` +/// use jiff::{civil::date, fmt::rfc2822::DateTimePrinter}; +/// +/// const PRINTER: DateTimePrinter = DateTimePrinter::new(); +/// +/// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).intz("Australia/Tasmania")?; +/// +/// let mut buf = String::new(); +/// PRINTER.print_zoned(&zdt, &mut buf)?; +/// assert_eq!(buf, "Sat, 15 Jun 2024 07:00:00 +1000"); +/// +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Example: using adapters with `std::io::Write` and `std::fmt::Write` +/// +/// By using the [`StdIoWrite`](super::StdIoWrite) and +/// [`StdFmtWrite`](super::StdFmtWrite) adapters, one can print datetimes +/// directly to implementations of `std::io::Write` and `std::fmt::Write`, +/// respectively. The example below demonstrates writing to anything +/// that implements `std::io::Write`. Similar code can be written for +/// `std::fmt::Write`. +/// +/// ```no_run +/// use std::{fs::File, io::{BufWriter, Write}, path::Path}; +/// +/// use jiff::{civil::date, fmt::{StdIoWrite, rfc2822::DateTimePrinter}}; +/// +/// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).intz("Asia/Kolkata")?; +/// +/// let path = Path::new("/tmp/output"); +/// let mut file = BufWriter::new(File::create(path)?); +/// DateTimePrinter::new().print_zoned(&zdt, StdIoWrite(&mut file)).unwrap(); +/// file.flush()?; +/// assert_eq!( +/// std::fs::read_to_string(path)?, +/// "Sat, 15 Jun 2024 07:00:00 +0530", +/// ); +/// +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug)] +pub struct DateTimePrinter { + // The RFC 2822 printer has no configuration at present. + _private: (), +} + +impl DateTimePrinter { + /// Create a new RFC 2822 datetime printer with the default configuration. + pub const fn new() -> DateTimePrinter { + DateTimePrinter { _private: () } + } + + /// Print a `Zoned` datetime to the given writer. + /// + /// 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. + /// + /// # 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 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). + /// + /// This can also 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")?; + /// + /// let mut buf = String::new(); + /// PRINTER.print_zoned(&zdt, &mut buf)?; + /// assert_eq!(buf, "Sat, 15 Jun 2024 07:00:00 -0400"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn print_zoned( + &self, + zdt: &Zoned, + wtr: W, + ) -> Result<(), Error> { + self.print_civil_with_offset(zdt.datetime(), Some(zdt.offset()), wtr) + } + + /// Print a `Timestamp` datetime to the given writer. + /// + /// 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. + /// + /// # Errors + /// + /// This 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). + /// + /// This can also 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::{fmt::rfc2822::DateTimePrinter, Timestamp}; + /// + /// let timestamp = Timestamp::from_second(1) + /// .expect("one second after Unix epoch is always valid"); + /// + /// let mut buf = String::new(); + /// DateTimePrinter::new().print_timestamp(×tamp, &mut buf)?; + /// assert_eq!(buf, "Thu, 1 Jan 1970 00:00:01 -0000"); + /// + /// # Ok::<(), Box>(()) + /// ``` + pub fn print_timestamp( + &self, + timestamp: &Timestamp, + wtr: W, + ) -> Result<(), Error> { + let dt = TimeZone::UTC.to_datetime(*timestamp); + self.print_civil_with_offset(dt, None, wtr) + } + + fn print_civil_with_offset( + &self, + dt: DateTime, + offset: Option, + mut wtr: W, + ) -> Result<(), Error> { + static FMT_DAY: DecimalFormatter = DecimalFormatter::new(); + static FMT_YEAR: DecimalFormatter = + DecimalFormatter::new().minimum_digits(4); + static FMT_TIME_UNIT: DecimalFormatter = + DecimalFormatter::new().minimum_digits(2); + + if dt.year() < 0 { + // RFC 2822 actually says the year must be at least 1900, but + // other implementations (like Chrono) allow any positive 4-digit + // year. + return Err(err!( + "datetime {dt} has negative year, \ + which cannot be formatted with RFC 2822", + )); + } + + wtr.write_str(weekday_abbrev(dt.weekday()))?; + wtr.write_str(", ")?; + wtr.write_int(&FMT_DAY, dt.day())?; + wtr.write_str(" ")?; + wtr.write_str(month_name(dt.month()))?; + wtr.write_str(" ")?; + wtr.write_int(&FMT_YEAR, dt.year())?; + wtr.write_str(" ")?; + wtr.write_int(&FMT_TIME_UNIT, dt.hour())?; + wtr.write_str(":")?; + wtr.write_int(&FMT_TIME_UNIT, dt.minute())?; + wtr.write_str(":")?; + wtr.write_int(&FMT_TIME_UNIT, dt.second())?; + wtr.write_str(" ")?; + + let Some(offset) = offset else { + wtr.write_str("-0000")?; + return Ok(()); + }; + wtr.write_str(if offset.is_negative() { "-" } else { "+" })?; + let mut hours = offset.part_hours_ranged().abs().get(); + let mut minutes = offset.part_minutes_ranged().abs().get(); + // RFC 2822, like RFC 3339, requires that time zone offsets are an + // integral number of minutes. While rounding based on seconds doesn't + // seem clearly indicated, we choose to do that here. An alternative + // would be to return an error. It isn't clear how important this is in + // practice though. + if offset.part_seconds_ranged().abs() >= 30 { + if minutes == 59 { + hours = hours.saturating_add(1); + minutes = 0; + } else { + minutes = minutes.saturating_add(1); + } + } + wtr.write_int(&FMT_TIME_UNIT, hours)?; + wtr.write_int(&FMT_TIME_UNIT, minutes)?; + Ok(()) + } +} + +fn weekday_abbrev(wd: Weekday) -> &'static str { + match wd { + Weekday::Sunday => "Sun", + Weekday::Monday => "Mon", + Weekday::Tuesday => "Tue", + Weekday::Wednesday => "Wed", + Weekday::Thursday => "Thu", + Weekday::Friday => "Fri", + Weekday::Saturday => "Sat", + } +} + +fn month_name(month: i8) -> &'static str { + match month { + 1 => "Jan", + 2 => "Feb", + 3 => "Mar", + 4 => "Apr", + 5 => "May", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Oct", + 11 => "Nov", + 12 => "Dec", + _ => unreachable!("invalid month value {month}"), + } +} + +#[cfg(test)] +mod tests { + use alloc::string::{String, ToString}; + + use crate::civil::date; + + use super::*; + + #[test] + fn ok_print_zoned() { + let p = |zdt: &Zoned| -> String { + let mut buf = String::new(); + DateTimePrinter::new().print_zoned(&zdt, &mut buf).unwrap(); + buf + }; + + let zdt = date(2024, 1, 10) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap(); + insta::assert_snapshot!(p(&zdt), @"Wed, 10 Jan 2024 05:34:45 -0500"); + + let zdt = date(2024, 2, 5) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap(); + insta::assert_snapshot!(p(&zdt), @"Mon, 5 Feb 2024 05:34:45 -0500"); + + let zdt = date(2024, 7, 31) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap(); + insta::assert_snapshot!(p(&zdt), @"Wed, 31 Jul 2024 05:34:45 -0400"); + + let zdt = date(2024, 3, 5).at(5, 34, 45, 0).intz("UTC").unwrap(); + // Notice that this prints a +0000 offset. + // But when printing a Timestamp, a -0000 offset is used. + // This is because in the case of Timestamp, the "true" + // offset is not known. + insta::assert_snapshot!(p(&zdt), @"Tue, 5 Mar 2024 05:34:45 +0000"); + } + + #[test] + fn ok_print_timestamp() { + let p = |ts: Timestamp| -> String { + let mut buf = String::new(); + DateTimePrinter::new().print_timestamp(&ts, &mut buf).unwrap(); + buf + }; + + let ts = date(2024, 1, 10) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap() + .timestamp(); + insta::assert_snapshot!(p(ts), @"Wed, 10 Jan 2024 10:34:45 -0000"); + + let ts = date(2024, 2, 5) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap() + .timestamp(); + insta::assert_snapshot!(p(ts), @"Mon, 5 Feb 2024 10:34:45 -0000"); + + let ts = date(2024, 7, 31) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap() + .timestamp(); + insta::assert_snapshot!(p(ts), @"Wed, 31 Jul 2024 09:34:45 -0000"); + + let ts = + date(2024, 3, 5).at(5, 34, 45, 0).intz("UTC").unwrap().timestamp(); + // Notice that this prints a +0000 offset. + // But when printing a Timestamp, a -0000 offset is used. + // This is because in the case of Timestamp, the "true" + // offset is not known. + insta::assert_snapshot!(p(ts), @"Tue, 5 Mar 2024 05:34:45 -0000"); + } + + #[test] + fn err_print_zoned() { + let p = |zdt: &Zoned| -> String { + let mut buf = String::new(); + DateTimePrinter::new() + .print_zoned(&zdt, &mut buf) + .unwrap_err() + .to_string() + }; + + let zdt = + date(-1, 1, 10).at(5, 34, 45, 0).intz("America/New_York").unwrap(); + insta::assert_snapshot!(p(&zdt), @"datetime -000001-01-10T05:34:45 has negative year, which cannot be formatted with RFC 2822"); + } + + #[test] + fn err_print_timestamp() { + let p = |ts: Timestamp| -> String { + let mut buf = String::new(); + DateTimePrinter::new() + .print_timestamp(&ts, &mut buf) + .unwrap_err() + .to_string() + }; + + let ts = date(-1, 1, 10) + .at(5, 34, 45, 0) + .intz("America/New_York") + .unwrap() + .timestamp(); + insta::assert_snapshot!(p(ts), @"datetime -000001-01-10T10:30:47 has negative year, which cannot be formatted with RFC 2822"); + } +} diff --git a/src/fmt/temporal/mod.rs b/src/fmt/temporal/mod.rs index f4d7a98..ccd2024 100644 --- a/src/fmt/temporal/mod.rs +++ b/src/fmt/temporal/mod.rs @@ -895,7 +895,7 @@ impl DateTimePrinter { self.p.print_zoned(zdt, wtr) } - /// Print an `Timestamp` datetime to the given writer. + /// Print a `Timestamp` datetime to the given writer. /// /// # Errors ///