jiff/fmt/rfc2822.rs
1/*!
2Support for printing and parsing instants using the [RFC 2822] datetime format.
3
4RFC 2822 is most commonly found when dealing with email messages.
5
6Since RFC 2822 only supports specifying a complete instant in time, the parser
7and printer in this module only use [`Zoned`] and [`Timestamp`]. If you need
8inexact time, you can get it from [`Zoned`] via [`Zoned::datetime`].
9
10[RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
11
12# Incomplete support
13
14The RFC 2822 support in this crate is technically incomplete. Specifically,
15it does not support parsing comments within folding whitespace. It will parse
16comments after the datetime itself (including nested comments). See [Issue
17#39][issue39] for an example. If you find a real world use case for parsing
18comments within whitespace at any point in the datetime string, please file
19an issue. That is, the main reason it isn't currently supported is because
20it didn't seem worth the implementation complexity to account for it. But if
21there are real world use cases that need it, then that would be sufficient
22justification for adding it.
23
24RFC 2822 support should otherwise be complete, including support for parsing
25obsolete offsets.
26
27[issue39]: https://github.com/BurntSushi/jiff/issues/39
28
29# Warning
30
31The RFC 2822 format only supports writing a precise instant in time
32expressed via a time zone offset. It does *not* support serializing
33the time zone itself. This means that if you format a zoned datetime
34in a time zone like `America/New_York` and then deserialize it, the
35zoned datetime you get back will be a "fixed offset" zoned datetime.
36This in turn means it will not perform daylight saving time safe
37arithmetic.
38
39Basically, you should use the RFC 2822 format if it's required (for
40example, when dealing with email). But you should not choose it as a
41general interchange format for new applications.
42*/
43
44use crate::{
45 civil::{Date, DateTime, Time, Weekday},
46 error::{fmt::rfc2822::Error as E, ErrorContext},
47 fmt::{buffer::BorrowedBuffer, Parsed, Write},
48 tz::{Offset, TimeZone},
49 util::{b, parse},
50 Error, Timestamp, Zoned,
51};
52
53/// The default date time parser that we use throughout Jiff.
54pub(crate) static DEFAULT_DATETIME_PARSER: DateTimeParser =
55 DateTimeParser::new();
56
57/// The default date time printer that we use throughout Jiff.
58pub(crate) static DEFAULT_DATETIME_PRINTER: DateTimePrinter =
59 DateTimePrinter::new();
60
61/// The maximum number bytes that can be written by the RFC 2822 printer.
62///
63/// We reserve a heap or stack buffer up front before printing, and we want to
64/// ensure we have enough space to write the longest possible RFC 2822 string.
65const PRINTER_MAX_BYTES_RFC2822: usize = 31;
66
67/// Same idea, but for RFC 9110.
68///
69/// The difference comes from always using `GMT` instead of, e.g., `-0400`.
70const PRINTER_MAX_BYTES_RFC9110: usize = 29;
71
72/// Convert a [`Zoned`] to an [RFC 2822] datetime string.
73///
74/// This is a convenience function for using [`DateTimePrinter`]. In
75/// particular, this always creates and allocates a new `String`. For writing
76/// to an existing string, or converting a [`Timestamp`] to an RFC 2822
77/// datetime string, you'll need to use `DateTimePrinter`.
78///
79/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
80///
81/// # Warning
82///
83/// The RFC 2822 format only supports writing a precise instant in time
84/// expressed via a time zone offset. It does *not* support serializing
85/// the time zone itself. This means that if you format a zoned datetime
86/// in a time zone like `America/New_York` and then deserialize it, the
87/// zoned datetime you get back will be a "fixed offset" zoned datetime.
88/// This in turn means it will not perform daylight saving time safe
89/// arithmetic.
90///
91/// Basically, you should use the RFC 2822 format if it's required (for
92/// example, when dealing with email). But you should not choose it as a
93/// general interchange format for new applications.
94///
95/// # Errors
96///
97/// This returns an error if the year corresponding to this timestamp cannot be
98/// represented in the RFC 2822 format. For example, a negative year.
99///
100/// # Example
101///
102/// This example shows how to convert a zoned datetime to the RFC 2822 format:
103///
104/// ```
105/// use jiff::{civil::date, fmt::rfc2822};
106///
107/// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).in_tz("Australia/Tasmania")?;
108/// assert_eq!(rfc2822::to_string(&zdt)?, "Sat, 15 Jun 2024 07:00:00 +1000");
109///
110/// # Ok::<(), Box<dyn std::error::Error>>(())
111/// ```
112#[cfg(feature = "alloc")]
113#[inline]
114pub fn to_string(zdt: &Zoned) -> Result<alloc::string::String, Error> {
115 let mut buf = alloc::string::String::new();
116 DEFAULT_DATETIME_PRINTER.print_zoned(zdt, &mut buf)?;
117 Ok(buf)
118}
119
120/// Parse an [RFC 2822] datetime string into a [`Zoned`].
121///
122/// This is a convenience function for using [`DateTimeParser`]. In particular,
123/// this takes a `&str` while the `DateTimeParser` accepts a `&[u8]`.
124/// Moreover, if any configuration options are added to RFC 2822 parsing (none
125/// currently exist at time of writing), then it will be necessary to use a
126/// `DateTimeParser` to toggle them. Additionally, a `DateTimeParser` is needed
127/// for parsing into a [`Timestamp`].
128///
129/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
130///
131/// # Warning
132///
133/// The RFC 2822 format only supports writing a precise instant in time
134/// expressed via a time zone offset. It does *not* support serializing
135/// the time zone itself. This means that if you format a zoned datetime
136/// in a time zone like `America/New_York` and then deserialize it, the
137/// zoned datetime you get back will be a "fixed offset" zoned datetime.
138/// This in turn means it will not perform daylight saving time safe
139/// arithmetic.
140///
141/// Basically, you should use the RFC 2822 format if it's required (for
142/// example, when dealing with email). But you should not choose it as a
143/// general interchange format for new applications.
144///
145/// # Errors
146///
147/// This returns an error if the datetime string given is invalid or if it
148/// is valid but doesn't fit in the datetime range supported by Jiff. For
149/// example, RFC 2822 supports offsets up to 99 hours and 59 minutes,
150/// but Jiff's maximum offset is 25 hours, 59 minutes and 59 seconds.
151///
152/// # Example
153///
154/// This example shows how serializing a zoned datetime to RFC 2822 format
155/// and then deserializing will drop information:
156///
157/// ```
158/// use jiff::{civil::date, fmt::rfc2822};
159///
160/// let zdt = date(2024, 7, 13)
161/// .at(15, 9, 59, 789_000_000)
162/// .in_tz("America/New_York")?;
163/// // The default format (i.e., Temporal) guarantees lossless
164/// // serialization.
165/// assert_eq!(zdt.to_string(), "2024-07-13T15:09:59.789-04:00[America/New_York]");
166///
167/// let rfc2822 = rfc2822::to_string(&zdt)?;
168/// // Notice that the time zone name and fractional seconds have been dropped!
169/// assert_eq!(rfc2822, "Sat, 13 Jul 2024 15:09:59 -0400");
170/// // And of course, if we parse it back, all that info is still lost.
171/// // Which means this `zdt` cannot do DST safe arithmetic!
172/// let zdt = rfc2822::parse(&rfc2822)?;
173/// assert_eq!(zdt.to_string(), "2024-07-13T15:09:59-04:00[-04:00]");
174///
175/// # Ok::<(), Box<dyn std::error::Error>>(())
176/// ```
177#[inline]
178pub fn parse(string: &str) -> Result<Zoned, Error> {
179 DEFAULT_DATETIME_PARSER.parse_zoned(string)
180}
181
182/// A parser for [RFC 2822] datetimes.
183///
184/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
185///
186/// # Warning
187///
188/// The RFC 2822 format only supports writing a precise instant in time
189/// expressed via a time zone offset. It does *not* support serializing
190/// the time zone itself. This means that if you format a zoned datetime
191/// in a time zone like `America/New_York` and then deserialize it, the
192/// zoned datetime you get back will be a "fixed offset" zoned datetime.
193/// This in turn means it will not perform daylight saving time safe
194/// arithmetic.
195///
196/// Basically, you should use the RFC 2822 format if it's required (for
197/// example, when dealing with email). But you should not choose it as a
198/// general interchange format for new applications.
199///
200/// # Example
201///
202/// This example shows how serializing a zoned datetime to RFC 2822 format
203/// and then deserializing will drop information:
204///
205/// ```
206/// use jiff::{civil::date, fmt::rfc2822};
207///
208/// let zdt = date(2024, 7, 13)
209/// .at(15, 9, 59, 789_000_000)
210/// .in_tz("America/New_York")?;
211/// // The default format (i.e., Temporal) guarantees lossless
212/// // serialization.
213/// assert_eq!(zdt.to_string(), "2024-07-13T15:09:59.789-04:00[America/New_York]");
214///
215/// let rfc2822 = rfc2822::to_string(&zdt)?;
216/// // Notice that the time zone name and fractional seconds have been dropped!
217/// assert_eq!(rfc2822, "Sat, 13 Jul 2024 15:09:59 -0400");
218/// // And of course, if we parse it back, all that info is still lost.
219/// // Which means this `zdt` cannot do DST safe arithmetic!
220/// let zdt = rfc2822::parse(&rfc2822)?;
221/// assert_eq!(zdt.to_string(), "2024-07-13T15:09:59-04:00[-04:00]");
222///
223/// # Ok::<(), Box<dyn std::error::Error>>(())
224/// ```
225#[derive(Debug)]
226pub struct DateTimeParser {
227 relaxed_weekday: bool,
228}
229
230impl DateTimeParser {
231 /// Create a new RFC 2822 datetime parser with the default configuration.
232 #[inline]
233 pub const fn new() -> DateTimeParser {
234 DateTimeParser { relaxed_weekday: false }
235 }
236
237 /// When enabled, parsing will permit the weekday to be inconsistent with
238 /// the date. When enabled, the weekday is still parsed and can result in
239 /// an error if it isn't _a_ valid weekday. Only the error checking for
240 /// whether it is _the_ correct weekday for the parsed date is disabled.
241 ///
242 /// This is sometimes useful for interaction with systems that don't do
243 /// strict error checking.
244 ///
245 /// This is disabled by default. And note that RFC 2822 compliance requires
246 /// that the weekday is consistent with the date.
247 ///
248 /// # Example
249 ///
250 /// ```
251 /// use jiff::{civil::date, fmt::rfc2822};
252 ///
253 /// let string = "Sun, 13 Jul 2024 15:09:59 -0400";
254 /// // The above normally results in an error, since 2024-07-13 is a
255 /// // Saturday:
256 /// assert!(rfc2822::parse(string).is_err());
257 /// // But we can relax the error checking:
258 /// static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new()
259 /// .relaxed_weekday(true);
260 /// assert_eq!(
261 /// P.parse_zoned(string)?,
262 /// date(2024, 7, 13).at(15, 9, 59, 0).in_tz("America/New_York")?,
263 /// );
264 /// // But note that something that isn't recognized as a valid weekday
265 /// // will still result in an error:
266 /// assert!(P.parse_zoned("Wat, 13 Jul 2024 15:09:59 -0400").is_err());
267 ///
268 /// # Ok::<(), Box<dyn std::error::Error>>(())
269 /// ```
270 #[inline]
271 pub const fn relaxed_weekday(self, yes: bool) -> DateTimeParser {
272 DateTimeParser { relaxed_weekday: yes, ..self }
273 }
274
275 /// Parse a datetime string into a [`Zoned`] value.
276 ///
277 /// Note that RFC 2822 does not support time zone annotations. The zoned
278 /// datetime returned will therefore always have a fixed offset time zone.
279 ///
280 /// # Warning
281 ///
282 /// The RFC 2822 format only supports writing a precise instant in time
283 /// expressed via a time zone offset. It does *not* support serializing
284 /// the time zone itself. This means that if you format a zoned datetime
285 /// in a time zone like `America/New_York` and then deserialize it, the
286 /// zoned datetime you get back will be a "fixed offset" zoned datetime.
287 /// This in turn means it will not perform daylight saving time safe
288 /// arithmetic.
289 ///
290 /// Basically, you should use the RFC 2822 format if it's required (for
291 /// example, when dealing with email). But you should not choose it as a
292 /// general interchange format for new applications.
293 ///
294 /// # Errors
295 ///
296 /// This returns an error if the datetime string given is invalid or if it
297 /// is valid but doesn't fit in the datetime range supported by Jiff. For
298 /// example, RFC 2822 supports offsets up to 99 hours and 59 minutes,
299 /// but Jiff's maximum offset is 25 hours, 59 minutes and 59 seconds.
300 ///
301 /// # Example
302 ///
303 /// This shows a basic example of parsing a `Timestamp` from an RFC 2822
304 /// datetime string.
305 ///
306 /// ```
307 /// use jiff::fmt::rfc2822::DateTimeParser;
308 ///
309 /// static PARSER: DateTimeParser = DateTimeParser::new();
310 ///
311 /// let zdt = PARSER.parse_zoned("Thu, 29 Feb 2024 05:34 -0500")?;
312 /// assert_eq!(zdt.to_string(), "2024-02-29T05:34:00-05:00[-05:00]");
313 ///
314 /// # Ok::<(), Box<dyn std::error::Error>>(())
315 /// ```
316 pub fn parse_zoned<I: AsRef<[u8]>>(
317 &self,
318 input: I,
319 ) -> Result<Zoned, Error> {
320 let input = input.as_ref();
321 let zdt = self
322 .parse_zoned_internal(input)
323 .context(E::FailedZoned)?
324 .into_full()?;
325 Ok(zdt)
326 }
327
328 /// Parse an RFC 2822 datetime string into a [`Timestamp`].
329 ///
330 /// # Errors
331 ///
332 /// This returns an error if the datetime string given is invalid or if it
333 /// is valid but doesn't fit in the datetime range supported by Jiff. For
334 /// example, RFC 2822 supports offsets up to 99 hours and 59 minutes,
335 /// but Jiff's maximum offset is 25 hours, 59 minutes and 59 seconds.
336 ///
337 /// # Example
338 ///
339 /// This shows a basic example of parsing a `Timestamp` from an RFC 2822
340 /// datetime string.
341 ///
342 /// ```
343 /// use jiff::fmt::rfc2822::DateTimeParser;
344 ///
345 /// static PARSER: DateTimeParser = DateTimeParser::new();
346 ///
347 /// let timestamp = PARSER.parse_timestamp("Thu, 29 Feb 2024 05:34 -0500")?;
348 /// assert_eq!(timestamp.to_string(), "2024-02-29T10:34:00Z");
349 ///
350 /// # Ok::<(), Box<dyn std::error::Error>>(())
351 /// ```
352 pub fn parse_timestamp<I: AsRef<[u8]>>(
353 &self,
354 input: I,
355 ) -> Result<Timestamp, Error> {
356 let input = input.as_ref();
357 let ts = self
358 .parse_timestamp_internal(input)
359 .context(E::FailedTimestamp)?
360 .into_full()?;
361 Ok(ts)
362 }
363
364 /// Parses an RFC 2822 datetime as a zoned datetime.
365 ///
366 /// Note that this doesn't check that the input has been completely
367 /// consumed.
368 #[cfg_attr(feature = "perf-inline", inline(always))]
369 fn parse_zoned_internal<'i>(
370 &self,
371 input: &'i [u8],
372 ) -> Result<Parsed<'i, Zoned>, Error> {
373 let Parsed { value: (dt, offset), input } =
374 self.parse_datetime_offset(input)?;
375 let ts = offset.to_timestamp(dt)?;
376 let zdt = ts.to_zoned(TimeZone::fixed(offset));
377 Ok(Parsed { value: zdt, input })
378 }
379
380 /// Parses an RFC 2822 datetime as a timestamp.
381 ///
382 /// Note that this doesn't check that the input has been completely
383 /// consumed.
384 #[cfg_attr(feature = "perf-inline", inline(always))]
385 fn parse_timestamp_internal<'i>(
386 &self,
387 input: &'i [u8],
388 ) -> Result<Parsed<'i, Timestamp>, Error> {
389 let Parsed { value: (dt, offset), input } =
390 self.parse_datetime_offset(input)?;
391 let ts = offset.to_timestamp(dt)?;
392 Ok(Parsed { value: ts, input })
393 }
394
395 /// Parse the entirety of the given input into RFC 2822 components: a civil
396 /// datetime and its offset.
397 ///
398 /// This also consumes any trailing (superfluous) whitespace.
399 #[cfg_attr(feature = "perf-inline", inline(always))]
400 fn parse_datetime_offset<'i>(
401 &self,
402 input: &'i [u8],
403 ) -> Result<Parsed<'i, (DateTime, Offset)>, Error> {
404 let input = input.as_ref();
405 let Parsed { value: dt, input } = self.parse_datetime(input)?;
406 let Parsed { value: offset, input } = self.parse_offset(input)?;
407 let Parsed { input, .. } = self.skip_whitespace(input);
408 let input = if input.is_empty() {
409 input
410 } else {
411 self.skip_comment(input)?.input
412 };
413 Ok(Parsed { value: (dt, offset), input })
414 }
415
416 /// Parses a civil datetime from an RFC 2822 string. The input may have
417 /// leading whitespace.
418 ///
419 /// This also parses and trailing whitespace, including requiring at least
420 /// one whitespace character.
421 ///
422 /// This basically parses everything except for the zone.
423 #[cfg_attr(feature = "perf-inline", inline(always))]
424 fn parse_datetime<'i>(
425 &self,
426 input: &'i [u8],
427 ) -> Result<Parsed<'i, DateTime>, Error> {
428 if input.is_empty() {
429 return Err(Error::from(E::Empty));
430 }
431 let Parsed { input, .. } = self.skip_whitespace(input);
432 if input.is_empty() {
433 return Err(Error::from(E::EmptyAfterWhitespace));
434 }
435 let Parsed { value: wd, input } = self.parse_weekday(input)?;
436 let Parsed { value: day, input } = self.parse_day(input)?;
437 let Parsed { value: month, input } = self.parse_month(input)?;
438 let Parsed { value: year, input } = self.parse_year(input)?;
439
440 let Parsed { value: hour, input } = self.parse_hour(input)?;
441 let Parsed { input, .. } = self.skip_whitespace(input);
442 let Parsed { input, .. } = self.parse_time_separator(input)?;
443 let Parsed { input, .. } = self.skip_whitespace(input);
444 let Parsed { value: minute, input } = self.parse_minute(input)?;
445
446 let Parsed { value: whitespace_after_minute, input } =
447 self.skip_whitespace(input);
448 let (second, input) = if !input.starts_with(b":") {
449 if !whitespace_after_minute {
450 return Err(Error::from(E::WhitespaceAfterTime));
451 }
452 (0, input)
453 } else {
454 let Parsed { input, .. } = self.parse_time_separator(input)?;
455 let Parsed { input, .. } = self.skip_whitespace(input);
456 let Parsed { value: second, input } = self.parse_second(input)?;
457 let Parsed { input, .. } = self.parse_whitespace(input)?;
458 (second, input)
459 };
460
461 let date = Date::new(year, month, day).context(E::InvalidDate)?;
462 // OK because hour, minute and second have been verified as being
463 // in bounds. And all combinations of such in-bound values are also
464 // valid `Time` values.
465 let time = Time::new(hour, minute, second, 0).unwrap();
466 let dt = DateTime::from_parts(date, time);
467 if let Some(wd) = wd {
468 if !self.relaxed_weekday && wd != dt.weekday() {
469 return Err(Error::from(E::InconsistentWeekday {
470 parsed: wd,
471 from_date: dt.weekday(),
472 }));
473 }
474 }
475 Ok(Parsed { value: dt, input })
476 }
477
478 /// Parses an optional weekday at the beginning of an RFC 2822 datetime.
479 ///
480 /// This expects that any optional whitespace preceding the start of an
481 /// optional day has been stripped and that the input has at least one
482 /// byte.
483 ///
484 /// When the first byte of the given input is a digit (or is empty), then
485 /// this returns `None`, as it implies a day is not present. But if it
486 /// isn't a digit, then we assume that it must be a weekday and return an
487 /// error based on that assumption if we couldn't recognize a weekday.
488 ///
489 /// If a weekday is parsed, then this also skips any trailing whitespace
490 /// (and requires at least one whitespace character).
491 #[cfg_attr(feature = "perf-inline", inline(always))]
492 fn parse_weekday<'i>(
493 &self,
494 input: &'i [u8],
495 ) -> Result<Parsed<'i, Option<Weekday>>, Error> {
496 // An empty input is invalid, but we let that case be
497 // handled by the caller. Otherwise, we know there MUST
498 // be a present day if the first character isn't an ASCII
499 // digit.
500 if matches!(input[0], b'0'..=b'9') {
501 return Ok(Parsed { value: None, input });
502 }
503 if let Ok(len) = u8::try_from(input.len()) {
504 if len < 4 {
505 return Err(Error::from(E::TooShortWeekday {
506 got_non_digit: input[0],
507 len,
508 }));
509 }
510 }
511 let b1 = input[0];
512 let b2 = input[1];
513 let b3 = input[2];
514 let wd = match &[
515 b1.to_ascii_lowercase(),
516 b2.to_ascii_lowercase(),
517 b3.to_ascii_lowercase(),
518 ] {
519 b"sun" => Weekday::Sunday,
520 b"mon" => Weekday::Monday,
521 b"tue" => Weekday::Tuesday,
522 b"wed" => Weekday::Wednesday,
523 b"thu" => Weekday::Thursday,
524 b"fri" => Weekday::Friday,
525 b"sat" => Weekday::Saturday,
526 _ => {
527 return Err(Error::from(E::InvalidWeekday {
528 got_non_digit: input[0],
529 }));
530 }
531 };
532 let Parsed { input, .. } = self.skip_whitespace(&input[3..]);
533 let Some(should_be_comma) = input.get(0).copied() else {
534 return Err(Error::from(E::EndOfInputComma));
535 };
536 if should_be_comma != b',' {
537 return Err(Error::from(E::UnexpectedByteComma {
538 byte: should_be_comma,
539 }));
540 }
541 let Parsed { input, .. } = self.skip_whitespace(&input[1..]);
542 Ok(Parsed { value: Some(wd), input })
543 }
544
545 /// Parses a 1 or 2 digit day.
546 ///
547 /// This assumes the input starts with what must be an ASCII digit (or it
548 /// may be empty).
549 ///
550 /// This also parses at least one mandatory whitespace character after the
551 /// day.
552 #[cfg_attr(feature = "perf-inline", inline(always))]
553 fn parse_day<'i>(&self, input: &'i [u8]) -> Result<Parsed<'i, i8>, Error> {
554 if input.is_empty() {
555 return Err(Error::from(E::EndOfInputDay));
556 }
557 let mut digits = 1;
558 if input.len() >= 2 && matches!(input[1], b'0'..=b'9') {
559 digits = 2;
560 }
561 let (day, input) = input.split_at(digits);
562 let day = b::Day::parse(day).context(E::ParseDay)?;
563 let Parsed { input, .. } =
564 self.parse_whitespace(input).context(E::WhitespaceAfterDay)?;
565 Ok(Parsed { value: day, input })
566 }
567
568 /// Parses an abbreviated month name.
569 ///
570 /// This assumes the input starts with what must be the beginning of a
571 /// month name (or the input may be empty).
572 ///
573 /// This also parses at least one mandatory whitespace character after the
574 /// month name.
575 #[cfg_attr(feature = "perf-inline", inline(always))]
576 fn parse_month<'i>(
577 &self,
578 input: &'i [u8],
579 ) -> Result<Parsed<'i, i8>, Error> {
580 if input.is_empty() {
581 return Err(Error::from(E::EndOfInputMonth));
582 }
583 if let Ok(len) = u8::try_from(input.len()) {
584 if len < 3 {
585 return Err(Error::from(E::TooShortMonth { len }));
586 }
587 }
588 let b1 = input[0].to_ascii_lowercase();
589 let b2 = input[1].to_ascii_lowercase();
590 let b3 = input[2].to_ascii_lowercase();
591 let month = match &[b1, b2, b3] {
592 b"jan" => 1,
593 b"feb" => 2,
594 b"mar" => 3,
595 b"apr" => 4,
596 b"may" => 5,
597 b"jun" => 6,
598 b"jul" => 7,
599 b"aug" => 8,
600 b"sep" => 9,
601 b"oct" => 10,
602 b"nov" => 11,
603 b"dec" => 12,
604 _ => return Err(Error::from(E::InvalidMonth)),
605 };
606 let Parsed { input, .. } = self
607 .parse_whitespace(&input[3..])
608 .context(E::WhitespaceAfterMonth)?;
609 Ok(Parsed { value: month, input })
610 }
611
612 /// Parses a 2, 3 or 4 digit year.
613 ///
614 /// This assumes the input starts with what must be an ASCII digit (or it
615 /// may be empty).
616 ///
617 /// This also parses at least one mandatory whitespace character after the
618 /// day.
619 ///
620 /// The 2 or 3 digit years are "obsolete," which we support by following
621 /// the rules in RFC 2822:
622 ///
623 /// > Where a two or three digit year occurs in a date, the year is to be
624 /// > interpreted as follows: If a two digit year is encountered whose
625 /// > value is between 00 and 49, the year is interpreted by adding 2000,
626 /// > ending up with a value between 2000 and 2049. If a two digit year is
627 /// > encountered with a value between 50 and 99, or any three digit year
628 /// > is encountered, the year is interpreted by adding 1900.
629 #[cfg_attr(feature = "perf-inline", inline(always))]
630 fn parse_year<'i>(
631 &self,
632 input: &'i [u8],
633 ) -> Result<Parsed<'i, i16>, Error> {
634 let mut digits = 0;
635 while digits <= 3
636 && !input[digits..].is_empty()
637 && matches!(input[digits], b'0'..=b'9')
638 {
639 digits += 1;
640 }
641 if let Ok(len) = u8::try_from(digits) {
642 if len <= 1 {
643 return Err(Error::from(E::TooShortYear { len }));
644 }
645 }
646 let (year, input) = input.split_at(digits);
647 let year = b::Year::parse(year).context(E::ParseYear)?;
648 let year = match digits {
649 2 if year <= 49 => year + 2000,
650 2 | 3 => year + 1900,
651 4 => year,
652 _ => unreachable!("digits={digits} must be 2, 3 or 4"),
653 };
654 let Parsed { input, .. } =
655 self.parse_whitespace(input).context(E::WhitespaceAfterYear)?;
656 Ok(Parsed { value: year, input })
657 }
658
659 /// Parses a 2-digit hour. This assumes the input begins with what should
660 /// be an ASCII digit. (i.e., It doesn't trim leading whitespace.)
661 ///
662 /// This parses a mandatory trailing `:`, advancing the input to
663 /// immediately after it.
664 #[cfg_attr(feature = "perf-inline", inline(always))]
665 fn parse_hour<'i>(
666 &self,
667 input: &'i [u8],
668 ) -> Result<Parsed<'i, i8>, Error> {
669 let (hour, input) = parse::split(input, 2).ok_or(E::EndOfInputHour)?;
670 let hour = b::Hour::parse(hour).context(E::ParseHour)?;
671 Ok(Parsed { value: hour, input })
672 }
673
674 /// Parses a 2-digit minute. This assumes the input begins with what should
675 /// be an ASCII digit. (i.e., It doesn't trim leading whitespace.)
676 #[cfg_attr(feature = "perf-inline", inline(always))]
677 fn parse_minute<'i>(
678 &self,
679 input: &'i [u8],
680 ) -> Result<Parsed<'i, i8>, Error> {
681 let (minute, input) =
682 parse::split(input, 2).ok_or(E::EndOfInputMinute)?;
683 let minute = b::Minute::parse(minute).context(E::ParseMinute)?;
684 Ok(Parsed { value: minute, input })
685 }
686
687 /// Parses a 2-digit second. This assumes the input begins with what should
688 /// be an ASCII digit. (i.e., It doesn't trim leading whitespace.)
689 #[cfg_attr(feature = "perf-inline", inline(always))]
690 fn parse_second<'i>(
691 &self,
692 input: &'i [u8],
693 ) -> Result<Parsed<'i, i8>, Error> {
694 let (second, input) =
695 parse::split(input, 2).ok_or(E::EndOfInputSecond)?;
696 let mut second =
697 b::LeapSecond::parse(second).context(E::ParseSecond)?;
698 if second == 60 {
699 second = 59;
700 }
701 Ok(Parsed { value: second, input })
702 }
703
704 /// Parses a time zone offset (including obsolete offsets like EDT).
705 ///
706 /// This assumes the offset must begin at the beginning of `input`. That
707 /// is, any leading whitespace should already have been trimmed.
708 #[cfg_attr(feature = "perf-inline", inline(always))]
709 fn parse_offset<'i>(
710 &self,
711 input: &'i [u8],
712 ) -> Result<Parsed<'i, Offset>, Error> {
713 let sign = input.get(0).copied().ok_or(E::EndOfInputOffset)?;
714 let sign = if sign == b'+' {
715 b::Sign::Positive
716 } else if sign == b'-' {
717 b::Sign::Negative
718 } else {
719 return self.parse_offset_obsolete(input);
720 };
721 let input = &input[1..];
722 let (hhmm, input) = parse::split(input, 4).ok_or(E::TooShortOffset)?;
723
724 let hh =
725 b::OffsetHours::parse(&hhmm[0..2]).context(E::ParseOffsetHour)?;
726 let mm = b::OffsetMinutes::parse(&hhmm[2..4])
727 .context(E::ParseOffsetMinute)?;
728
729 let seconds = sign * (i32::from(hh) * 3_600 + i32::from(mm) * 60);
730 // OK because we check the bounds of both hours and minutes.
731 let offset = Offset::from_seconds(seconds).unwrap();
732 Ok(Parsed { value: offset, input })
733 }
734
735 /// Parses an obsolete time zone offset.
736 #[inline(never)]
737 fn parse_offset_obsolete<'i>(
738 &self,
739 input: &'i [u8],
740 ) -> Result<Parsed<'i, Offset>, Error> {
741 let mut letters = [0; 5];
742 let mut len = 0;
743 while len <= 4
744 && !input[len..].is_empty()
745 && !is_whitespace(input[len])
746 {
747 letters[len] = input[len].to_ascii_lowercase();
748 len += 1;
749 }
750 if len == 0 {
751 return Err(Error::from(E::WhitespaceAfterTimeForObsoleteOffset));
752 }
753 let offset = match &letters[..len] {
754 b"ut" | b"gmt" | b"z" => Offset::UTC,
755 b"est" => Offset::constant(-5),
756 b"edt" => Offset::constant(-4),
757 b"cst" => Offset::constant(-6),
758 b"cdt" => Offset::constant(-5),
759 b"mst" => Offset::constant(-7),
760 b"mdt" => Offset::constant(-6),
761 b"pst" => Offset::constant(-8),
762 b"pdt" => Offset::constant(-7),
763 name => {
764 if name.len() == 1
765 && matches!(name[0], b'a'..=b'i' | b'k'..=b'z')
766 {
767 // Section 4.3 indicates these as military time:
768 //
769 // > The 1 character military time zones were defined in
770 // > a non-standard way in [RFC822] and are therefore
771 // > unpredictable in their meaning. The original
772 // > definitions of the military zones "A" through "I" are
773 // > equivalent to "+0100" through "+0900" respectively;
774 // > "K", "L", and "M" are equivalent to "+1000", "+1100",
775 // > and "+1200" respectively; "N" through "Y" are
776 // > equivalent to "-0100" through "-1200" respectively;
777 // > and "Z" is equivalent to "+0000". However, because of
778 // > the error in [RFC822], they SHOULD all be considered
779 // > equivalent to "-0000" unless there is out-of-band
780 // > information confirming their meaning.
781 //
782 // So just treat them as UTC.
783 Offset::UTC
784 } else if name.len() >= 3
785 && name.iter().all(|&b| matches!(b, b'a'..=b'z'))
786 {
787 // Section 4.3 also says that anything that _looks_ like a
788 // zone name should just be -0000 too:
789 //
790 // > Other multi-character (usually between 3 and 5)
791 // > alphabetic time zones have been used in Internet
792 // > messages. Any such time zone whose meaning is not
793 // > known SHOULD be considered equivalent to "-0000"
794 // > unless there is out-of-band information confirming
795 // > their meaning.
796 Offset::UTC
797 } else {
798 // But anything else we throw our hands up I guess.
799 return Err(Error::from(E::InvalidObsoleteOffset));
800 }
801 }
802 };
803 Ok(Parsed { value: offset, input: &input[len..] })
804 }
805
806 /// Parses a time separator. This returns an error if one couldn't be
807 /// found.
808 #[cfg_attr(feature = "perf-inline", inline(always))]
809 fn parse_time_separator<'i>(
810 &self,
811 input: &'i [u8],
812 ) -> Result<Parsed<'i, ()>, Error> {
813 if input.is_empty() {
814 return Err(Error::from(E::EndOfInputTimeSeparator));
815 }
816 if input[0] != b':' {
817 return Err(Error::from(E::UnexpectedByteTimeSeparator {
818 byte: input[0],
819 }));
820 }
821 Ok(Parsed { value: (), input: &input[1..] })
822 }
823
824 /// Parses at least one whitespace character. If no whitespace was found,
825 /// then this returns an error.
826 #[cfg_attr(feature = "perf-inline", inline(always))]
827 fn parse_whitespace<'i>(
828 &self,
829 input: &'i [u8],
830 ) -> Result<Parsed<'i, ()>, Error> {
831 let Parsed { input, value: had_whitespace } =
832 self.skip_whitespace(input);
833 if !had_whitespace {
834 return Err(Error::from(E::WhitespaceAfterTime));
835 }
836 Ok(Parsed { value: (), input })
837 }
838
839 /// Skips over any ASCII whitespace at the beginning of `input`.
840 ///
841 /// This returns the input unchanged if it does not begin with whitespace.
842 /// The resulting value is `true` if any whitespace was consumed,
843 /// and `false` if none was.
844 #[cfg_attr(feature = "perf-inline", inline(always))]
845 fn skip_whitespace<'i>(&self, mut input: &'i [u8]) -> Parsed<'i, bool> {
846 let mut found_whitespace = false;
847 while input.first().map_or(false, |&b| is_whitespace(b)) {
848 input = &input[1..];
849 found_whitespace = true;
850 }
851 Parsed { value: found_whitespace, input }
852 }
853
854 /// This attempts to parse and skip any trailing "comment" in an RFC 2822
855 /// datetime.
856 ///
857 /// This is a bit more relaxed than what RFC 2822 specifies. We basically
858 /// just try to balance parenthesis and skip over escapes.
859 ///
860 /// This assumes that if a comment exists, its opening parenthesis is at
861 /// the beginning of `input`. That is, any leading whitespace has been
862 /// stripped.
863 #[inline(never)]
864 fn skip_comment<'i>(
865 &self,
866 mut input: &'i [u8],
867 ) -> Result<Parsed<'i, ()>, Error> {
868 if !input.starts_with(b"(") {
869 return Ok(Parsed { value: (), input });
870 }
871 input = &input[1..];
872 let mut depth: u8 = 1;
873 let mut escape = false;
874 for byte in input.iter().copied() {
875 input = &input[1..];
876 if escape {
877 escape = false;
878 } else if byte == b'\\' {
879 escape = true;
880 } else if byte == b')' {
881 // I believe this error case is actually impossible, since as
882 // soon as we hit 0, we break out. If there is more "comment,"
883 // then it will flag an error as unparsed input.
884 depth = depth
885 .checked_sub(1)
886 .ok_or(E::CommentClosingParenWithoutOpen)?;
887 if depth == 0 {
888 break;
889 }
890 } else if byte == b'(' {
891 depth = depth
892 .checked_add(1)
893 .ok_or(E::CommentTooManyNestedParens)?;
894 }
895 }
896 if depth > 0 {
897 return Err(Error::from(E::CommentOpeningParenWithoutClose));
898 }
899 let Parsed { input, .. } = self.skip_whitespace(input);
900 Ok(Parsed { value: (), input })
901 }
902}
903
904/// A printer for [RFC 2822] datetimes.
905///
906/// This printer converts an in memory representation of a precise instant in
907/// time to an RFC 2822 formatted string. That is, [`Zoned`] or [`Timestamp`],
908/// since all other datetime types in Jiff are inexact.
909///
910/// [RFC 2822]: https://datatracker.ietf.org/doc/html/rfc2822
911///
912/// # Warning
913///
914/// The RFC 2822 format only supports writing a precise instant in time
915/// expressed via a time zone offset. It does *not* support serializing
916/// the time zone itself. This means that if you format a zoned datetime
917/// in a time zone like `America/New_York` and then deserialize it, the
918/// zoned datetime you get back will be a "fixed offset" zoned datetime.
919/// This in turn means it will not perform daylight saving time safe
920/// arithmetic.
921///
922/// Basically, you should use the RFC 2822 format if it's required (for
923/// example, when dealing with email). But you should not choose it as a
924/// general interchange format for new applications.
925///
926/// # Example
927///
928/// This example shows how to convert a zoned datetime to the RFC 2822 format:
929///
930/// ```
931/// use jiff::{civil::date, fmt::rfc2822::DateTimePrinter};
932///
933/// const PRINTER: DateTimePrinter = DateTimePrinter::new();
934///
935/// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).in_tz("Australia/Tasmania")?;
936///
937/// let mut buf = String::new();
938/// PRINTER.print_zoned(&zdt, &mut buf)?;
939/// assert_eq!(buf, "Sat, 15 Jun 2024 07:00:00 +1000");
940///
941/// # Ok::<(), Box<dyn std::error::Error>>(())
942/// ```
943///
944/// # Example: using adapters with `std::io::Write` and `std::fmt::Write`
945///
946/// By using the [`StdIoWrite`](super::StdIoWrite) and
947/// [`StdFmtWrite`](super::StdFmtWrite) adapters, one can print datetimes
948/// directly to implementations of `std::io::Write` and `std::fmt::Write`,
949/// respectively. The example below demonstrates writing to anything
950/// that implements `std::io::Write`. Similar code can be written for
951/// `std::fmt::Write`.
952///
953/// ```no_run
954/// use std::{fs::File, io::{BufWriter, Write}, path::Path};
955///
956/// use jiff::{civil::date, fmt::{StdIoWrite, rfc2822::DateTimePrinter}};
957///
958/// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).in_tz("Asia/Kolkata")?;
959///
960/// let path = Path::new("/tmp/output");
961/// let mut file = BufWriter::new(File::create(path)?);
962/// DateTimePrinter::new().print_zoned(&zdt, StdIoWrite(&mut file)).unwrap();
963/// file.flush()?;
964/// assert_eq!(
965/// std::fs::read_to_string(path)?,
966/// "Sat, 15 Jun 2024 07:00:00 +0530",
967/// );
968///
969/// # Ok::<(), Box<dyn std::error::Error>>(())
970/// ```
971#[derive(Debug)]
972pub struct DateTimePrinter {
973 // The RFC 2822 printer has no configuration at present.
974 _private: (),
975}
976
977impl DateTimePrinter {
978 /// Create a new RFC 2822 datetime printer with the default configuration.
979 #[inline]
980 pub const fn new() -> DateTimePrinter {
981 DateTimePrinter { _private: () }
982 }
983
984 /// Format a `Zoned` datetime into a string.
985 ///
986 /// This never emits `-0000` as the offset in the RFC 2822 format. If you
987 /// desire a `-0000` offset, use [`DateTimePrinter::print_timestamp`] via
988 /// [`Zoned::timestamp`].
989 ///
990 /// Moreover, since RFC 2822 does not support fractional seconds, this
991 /// routine prints the zoned datetime as if truncating any fractional
992 /// seconds.
993 ///
994 /// This is a convenience routine for [`DateTimePrinter::print_zoned`]
995 /// with a `String`.
996 ///
997 /// # Warning
998 ///
999 /// The RFC 2822 format only supports writing a precise instant in time
1000 /// expressed via a time zone offset. It does *not* support serializing
1001 /// the time zone itself. This means that if you format a zoned datetime
1002 /// in a time zone like `America/New_York` and then deserialize it, the
1003 /// zoned datetime you get back will be a "fixed offset" zoned datetime.
1004 /// This in turn means it will not perform daylight saving time safe
1005 /// arithmetic.
1006 ///
1007 /// Basically, you should use the RFC 2822 format if it's required (for
1008 /// example, when dealing with email). But you should not choose it as a
1009 /// general interchange format for new applications.
1010 ///
1011 /// # Errors
1012 ///
1013 /// This can return an error if the year corresponding to this timestamp
1014 /// cannot be represented in the RFC 2822 format. For example, a negative
1015 /// year.
1016 ///
1017 /// # Example
1018 ///
1019 /// ```
1020 /// use jiff::{civil::date, fmt::rfc2822::DateTimePrinter};
1021 ///
1022 /// const PRINTER: DateTimePrinter = DateTimePrinter::new();
1023 ///
1024 /// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).in_tz("America/New_York")?;
1025 /// assert_eq!(
1026 /// PRINTER.zoned_to_string(&zdt)?,
1027 /// "Sat, 15 Jun 2024 07:00:00 -0400",
1028 /// );
1029 ///
1030 /// # Ok::<(), Box<dyn std::error::Error>>(())
1031 /// ```
1032 #[cfg(feature = "alloc")]
1033 pub fn zoned_to_string(
1034 &self,
1035 zdt: &Zoned,
1036 ) -> Result<alloc::string::String, Error> {
1037 // Writing directly into the unused capacity of a `String` saves about
1038 // 40% on a micro-benchmark compared to just passing a `&mut String`
1039 // to `print_zoned`.
1040 let mut buf =
1041 alloc::string::String::with_capacity(PRINTER_MAX_BYTES_RFC2822);
1042 self.print_zoned(zdt, &mut buf)?;
1043 Ok(buf)
1044 }
1045
1046 /// Format a `Timestamp` datetime into a string.
1047 ///
1048 /// This always emits `-0000` as the offset in the RFC 2822 format. If you
1049 /// desire a `+0000` offset, use [`DateTimePrinter::print_zoned`] with a
1050 /// zoned datetime with [`TimeZone::UTC`].
1051 ///
1052 /// Moreover, since RFC 2822 does not support fractional seconds, this
1053 /// routine prints the timestamp as if truncating any fractional seconds.
1054 ///
1055 /// This is a convenience routine for [`DateTimePrinter::print_timestamp`]
1056 /// with a `String`.
1057 ///
1058 /// # Errors
1059 ///
1060 /// This returns an error if the year corresponding to this
1061 /// timestamp cannot be represented in the RFC 2822 format. For example, a
1062 /// negative year.
1063 ///
1064 /// # Example
1065 ///
1066 /// ```
1067 /// use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp};
1068 ///
1069 /// let timestamp = Timestamp::from_second(1)
1070 /// .expect("one second after Unix epoch is always valid");
1071 /// assert_eq!(
1072 /// DateTimePrinter::new().timestamp_to_string(×tamp)?,
1073 /// "Thu, 1 Jan 1970 00:00:01 -0000",
1074 /// );
1075 ///
1076 /// # Ok::<(), Box<dyn std::error::Error>>(())
1077 /// ```
1078 #[cfg(feature = "alloc")]
1079 pub fn timestamp_to_string(
1080 &self,
1081 timestamp: &Timestamp,
1082 ) -> Result<alloc::string::String, Error> {
1083 let mut buf =
1084 alloc::string::String::with_capacity(PRINTER_MAX_BYTES_RFC2822);
1085 self.print_timestamp(timestamp, &mut buf)?;
1086 Ok(buf)
1087 }
1088
1089 /// Format a `Timestamp` datetime into a string in a way that is explicitly
1090 /// compatible with [RFC 9110]. This is typically useful in contexts where
1091 /// strict compatibility with HTTP is desired.
1092 ///
1093 /// This always emits `GMT` as the offset and always uses two digits for
1094 /// the day. This results in a fixed length format that always uses 29
1095 /// characters.
1096 ///
1097 /// Since neither RFC 2822 nor RFC 9110 supports fractional seconds, this
1098 /// routine prints the timestamp as if truncating any fractional seconds.
1099 ///
1100 /// This is a convenience routine for
1101 /// [`DateTimePrinter::print_timestamp_rfc9110`] with a `String`.
1102 ///
1103 /// # Errors
1104 ///
1105 /// This returns an error if the year corresponding to this timestamp
1106 /// cannot be represented in the RFC 2822 or RFC 9110 format. For example,
1107 /// a negative year.
1108 ///
1109 /// # Example
1110 ///
1111 /// ```
1112 /// use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp};
1113 ///
1114 /// let timestamp = Timestamp::from_second(1)
1115 /// .expect("one second after Unix epoch is always valid");
1116 /// assert_eq!(
1117 /// DateTimePrinter::new().timestamp_to_rfc9110_string(×tamp)?,
1118 /// "Thu, 01 Jan 1970 00:00:01 GMT",
1119 /// );
1120 ///
1121 /// # Ok::<(), Box<dyn std::error::Error>>(())
1122 /// ```
1123 ///
1124 /// [RFC 9110]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.7-15
1125 #[cfg(feature = "alloc")]
1126 pub fn timestamp_to_rfc9110_string(
1127 &self,
1128 timestamp: &Timestamp,
1129 ) -> Result<alloc::string::String, Error> {
1130 let mut buf =
1131 alloc::string::String::with_capacity(PRINTER_MAX_BYTES_RFC9110);
1132 self.print_timestamp_rfc9110(timestamp, &mut buf)?;
1133 Ok(buf)
1134 }
1135
1136 /// Print a `Zoned` datetime to the given writer.
1137 ///
1138 /// This never emits `-0000` as the offset in the RFC 2822 format. If you
1139 /// desire a `-0000` offset, use [`DateTimePrinter::print_timestamp`] via
1140 /// [`Zoned::timestamp`].
1141 ///
1142 /// Moreover, since RFC 2822 does not support fractional seconds, this
1143 /// routine prints the zoned datetime as if truncating any fractional
1144 /// seconds.
1145 ///
1146 /// # Warning
1147 ///
1148 /// The RFC 2822 format only supports writing a precise instant in time
1149 /// expressed via a time zone offset. It does *not* support serializing
1150 /// the time zone itself. This means that if you format a zoned datetime
1151 /// in a time zone like `America/New_York` and then deserialize it, the
1152 /// zoned datetime you get back will be a "fixed offset" zoned datetime.
1153 /// This in turn means it will not perform daylight saving time safe
1154 /// arithmetic.
1155 ///
1156 /// Basically, you should use the RFC 2822 format if it's required (for
1157 /// example, when dealing with email). But you should not choose it as a
1158 /// general interchange format for new applications.
1159 ///
1160 /// # Errors
1161 ///
1162 /// This returns an error when writing to the given [`Write`]
1163 /// implementation would fail. Some such implementations, like for `String`
1164 /// and `Vec<u8>`, never fail (unless memory allocation fails).
1165 ///
1166 /// This can also return an error if the year corresponding to this
1167 /// timestamp cannot be represented in the RFC 2822 format. For example, a
1168 /// negative year.
1169 ///
1170 /// # Example
1171 ///
1172 /// ```
1173 /// use jiff::{civil::date, fmt::rfc2822::DateTimePrinter};
1174 ///
1175 /// const PRINTER: DateTimePrinter = DateTimePrinter::new();
1176 ///
1177 /// let zdt = date(2024, 6, 15).at(7, 0, 0, 0).in_tz("America/New_York")?;
1178 ///
1179 /// let mut buf = String::new();
1180 /// PRINTER.print_zoned(&zdt, &mut buf)?;
1181 /// assert_eq!(buf, "Sat, 15 Jun 2024 07:00:00 -0400");
1182 ///
1183 /// # Ok::<(), Box<dyn std::error::Error>>(())
1184 /// ```
1185 pub fn print_zoned<W: Write>(
1186 &self,
1187 zdt: &Zoned,
1188 mut wtr: W,
1189 ) -> Result<(), Error> {
1190 BorrowedBuffer::with_writer::<PRINTER_MAX_BYTES_RFC2822>(
1191 &mut wtr,
1192 PRINTER_MAX_BYTES_RFC2822,
1193 |bbuf| {
1194 self.print_civil_with_offset(
1195 zdt.datetime(),
1196 Some(zdt.offset()),
1197 bbuf,
1198 )
1199 },
1200 )
1201 }
1202
1203 /// Print a `Timestamp` datetime to the given writer.
1204 ///
1205 /// This always emits `-0000` as the offset in the RFC 2822 format. If you
1206 /// desire a `+0000` offset, use [`DateTimePrinter::print_zoned`] with a
1207 /// zoned datetime with [`TimeZone::UTC`].
1208 ///
1209 /// Moreover, since RFC 2822 does not support fractional seconds, this
1210 /// routine prints the timestamp as if truncating any fractional seconds.
1211 ///
1212 /// # Errors
1213 ///
1214 /// This returns an error when writing to the given [`Write`]
1215 /// implementation would fail. Some such implementations, like for `String`
1216 /// and `Vec<u8>`, never fail (unless memory allocation fails).
1217 ///
1218 /// This can also return an error if the year corresponding to this
1219 /// timestamp cannot be represented in the RFC 2822 format. For example, a
1220 /// negative year.
1221 ///
1222 /// # Example
1223 ///
1224 /// ```
1225 /// use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp};
1226 ///
1227 /// let timestamp = Timestamp::from_second(1)
1228 /// .expect("one second after Unix epoch is always valid");
1229 ///
1230 /// let mut buf = String::new();
1231 /// DateTimePrinter::new().print_timestamp(×tamp, &mut buf)?;
1232 /// assert_eq!(buf, "Thu, 1 Jan 1970 00:00:01 -0000");
1233 ///
1234 /// # Ok::<(), Box<dyn std::error::Error>>(())
1235 /// ```
1236 pub fn print_timestamp<W: Write>(
1237 &self,
1238 timestamp: &Timestamp,
1239 mut wtr: W,
1240 ) -> Result<(), Error> {
1241 let dt = TimeZone::UTC.to_datetime(*timestamp);
1242 BorrowedBuffer::with_writer::<PRINTER_MAX_BYTES_RFC2822>(
1243 &mut wtr,
1244 PRINTER_MAX_BYTES_RFC2822,
1245 |bbuf| self.print_civil_with_offset(dt, None, bbuf),
1246 )
1247 }
1248
1249 /// Print a `Timestamp` datetime to the given writer in a way that is
1250 /// explicitly compatible with [RFC 9110]. This is typically useful in
1251 /// contexts where strict compatibility with HTTP is desired.
1252 ///
1253 /// This always emits `GMT` as the offset and always uses two digits for
1254 /// the day. This results in a fixed length format that always uses 29
1255 /// characters.
1256 ///
1257 /// Since neither RFC 2822 nor RFC 9110 supports fractional seconds, this
1258 /// routine prints the timestamp as if truncating any fractional seconds.
1259 ///
1260 /// # Errors
1261 ///
1262 /// This returns an error when writing to the given [`Write`]
1263 /// implementation would fail. Some such implementations, like for `String`
1264 /// and `Vec<u8>`, never fail (unless memory allocation fails).
1265 ///
1266 /// This can also return an error if the year corresponding to this
1267 /// timestamp cannot be represented in the RFC 2822 or RFC 9110 format. For
1268 /// example, a negative year.
1269 ///
1270 /// # Example
1271 ///
1272 /// ```
1273 /// use jiff::{fmt::rfc2822::DateTimePrinter, Timestamp};
1274 ///
1275 /// let timestamp = Timestamp::from_second(1)
1276 /// .expect("one second after Unix epoch is always valid");
1277 ///
1278 /// let mut buf = String::new();
1279 /// DateTimePrinter::new().print_timestamp_rfc9110(×tamp, &mut buf)?;
1280 /// assert_eq!(buf, "Thu, 01 Jan 1970 00:00:01 GMT");
1281 ///
1282 /// # Ok::<(), Box<dyn std::error::Error>>(())
1283 /// ```
1284 ///
1285 /// [RFC 9110]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.7-15
1286 pub fn print_timestamp_rfc9110<W: Write>(
1287 &self,
1288 timestamp: &Timestamp,
1289 mut wtr: W,
1290 ) -> Result<(), Error> {
1291 let dt = TimeZone::UTC.to_datetime(*timestamp);
1292 BorrowedBuffer::with_writer::<PRINTER_MAX_BYTES_RFC9110>(
1293 &mut wtr,
1294 PRINTER_MAX_BYTES_RFC9110,
1295 |bbuf| self.print_civil_always_utc(dt, bbuf),
1296 )
1297 }
1298
1299 #[inline(never)]
1300 fn print_civil_with_offset(
1301 &self,
1302 dt: DateTime,
1303 offset: Option<Offset>,
1304 buf: &mut BorrowedBuffer<'_>,
1305 ) -> Result<(), Error> {
1306 if dt.year() < 0 {
1307 // RFC 2822 actually says the year must be at least 1900, but
1308 // other implementations (like Chrono) allow any positive 4-digit
1309 // year.
1310 return Err(Error::from(E::NegativeYear));
1311 }
1312
1313 buf.write_str(weekday_abbrev(dt.weekday()));
1314 buf.write_str(", ");
1315 buf.write_int(dt.day().unsigned_abs());
1316 buf.write_ascii_char(b' ');
1317 buf.write_str(month_name(dt.month()));
1318 buf.write_ascii_char(b' ');
1319 buf.write_int_pad4(dt.year().unsigned_abs());
1320 buf.write_ascii_char(b' ');
1321 buf.write_int_pad2(dt.hour().unsigned_abs());
1322 buf.write_ascii_char(b':');
1323 buf.write_int_pad2(dt.minute().unsigned_abs());
1324 buf.write_ascii_char(b':');
1325 buf.write_int_pad2(dt.second().unsigned_abs());
1326 buf.write_ascii_char(b' ');
1327
1328 let Some(offset) = offset else {
1329 buf.write_str("-0000");
1330 return Ok(());
1331 };
1332 buf.write_ascii_char(if offset.is_negative() { b'-' } else { b'+' });
1333 let (offset_hours, offset_minutes) = offset.round_to_nearest_minute();
1334 buf.write_int_pad2(offset_hours);
1335 buf.write_int_pad2(offset_minutes);
1336
1337 Ok(())
1338 }
1339
1340 #[inline(never)]
1341 fn print_civil_always_utc(
1342 &self,
1343 dt: DateTime,
1344 buf: &mut BorrowedBuffer<'_>,
1345 ) -> Result<(), Error> {
1346 if dt.year() < 0 {
1347 // RFC 2822 actually says the year must be at least 1900, but
1348 // other implementations (like Chrono) allow any positive 4-digit
1349 // year.
1350 return Err(Error::from(E::NegativeYear));
1351 }
1352
1353 buf.write_str(weekday_abbrev(dt.weekday()));
1354 buf.write_str(", ");
1355 buf.write_int_pad2(dt.day().unsigned_abs());
1356 buf.write_str(" ");
1357 buf.write_str(month_name(dt.month()));
1358 buf.write_str(" ");
1359 buf.write_int_pad4(dt.year().unsigned_abs());
1360 buf.write_str(" ");
1361 buf.write_int_pad2(dt.hour().unsigned_abs());
1362 buf.write_str(":");
1363 buf.write_int_pad2(dt.minute().unsigned_abs());
1364 buf.write_str(":");
1365 buf.write_int_pad2(dt.second().unsigned_abs());
1366 buf.write_str(" ");
1367 buf.write_str("GMT");
1368 Ok(())
1369 }
1370}
1371
1372fn weekday_abbrev(wd: Weekday) -> &'static str {
1373 match wd {
1374 Weekday::Sunday => "Sun",
1375 Weekday::Monday => "Mon",
1376 Weekday::Tuesday => "Tue",
1377 Weekday::Wednesday => "Wed",
1378 Weekday::Thursday => "Thu",
1379 Weekday::Friday => "Fri",
1380 Weekday::Saturday => "Sat",
1381 }
1382}
1383
1384fn month_name(month: i8) -> &'static str {
1385 match month {
1386 1 => "Jan",
1387 2 => "Feb",
1388 3 => "Mar",
1389 4 => "Apr",
1390 5 => "May",
1391 6 => "Jun",
1392 7 => "Jul",
1393 8 => "Aug",
1394 9 => "Sep",
1395 10 => "Oct",
1396 11 => "Nov",
1397 12 => "Dec",
1398 _ => unreachable!("invalid month value {month}"),
1399 }
1400}
1401
1402/// Returns true if the given byte is "whitespace" as defined by RFC 2822.
1403///
1404/// From S2.2.2:
1405///
1406/// > Many of these tokens are allowed (according to their syntax) to be
1407/// > introduced or end with comments (as described in section 3.2.3) as well
1408/// > as the space (SP, ASCII value 32) and horizontal tab (HTAB, ASCII value
1409/// > 9) characters (together known as the white space characters, WSP), and
1410/// > those WSP characters are subject to header "folding" and "unfolding" as
1411/// > described in section 2.2.3.
1412///
1413/// In other words, ASCII space or tab.
1414///
1415/// With all that said, it seems odd to limit this to just spaces or tabs, so
1416/// we relax this and let it absorb any kind of ASCII whitespace. This also
1417/// handles, I believe, most cases of "folding" whitespace. (By treating `\r`
1418/// and `\n` as whitespace.)
1419fn is_whitespace(byte: u8) -> bool {
1420 byte.is_ascii_whitespace()
1421}
1422
1423#[cfg(feature = "alloc")]
1424#[cfg(test)]
1425mod tests {
1426 use alloc::string::{String, ToString};
1427
1428 use crate::civil::date;
1429
1430 use super::*;
1431
1432 #[test]
1433 fn ok_parse_basic() {
1434 let p = |input| DateTimeParser::new().parse_zoned(input).unwrap();
1435
1436 insta::assert_debug_snapshot!(
1437 p("Wed, 10 Jan 2024 05:34:45 -0500"),
1438 @"2024-01-10T05:34:45-05:00[-05:00]",
1439 );
1440 insta::assert_debug_snapshot!(
1441 p("Tue, 9 Jan 2024 05:34:45 -0500"),
1442 @"2024-01-09T05:34:45-05:00[-05:00]",
1443 );
1444 insta::assert_debug_snapshot!(
1445 p("Tue, 09 Jan 2024 05:34:45 -0500"),
1446 @"2024-01-09T05:34:45-05:00[-05:00]",
1447 );
1448 insta::assert_debug_snapshot!(
1449 p("10 Jan 2024 05:34:45 -0500"),
1450 @"2024-01-10T05:34:45-05:00[-05:00]",
1451 );
1452 insta::assert_debug_snapshot!(
1453 p("10 Jan 2024 05:34 -0500"),
1454 @"2024-01-10T05:34:00-05:00[-05:00]",
1455 );
1456 insta::assert_debug_snapshot!(
1457 p("10 Jan 2024 05:34:45 +0500"),
1458 @"2024-01-10T05:34:45+05:00[+05:00]",
1459 );
1460 insta::assert_debug_snapshot!(
1461 p("Thu, 29 Feb 2024 05:34 -0500"),
1462 @"2024-02-29T05:34:00-05:00[-05:00]",
1463 );
1464
1465 // leap second constraining
1466 insta::assert_debug_snapshot!(
1467 p("10 Jan 2024 05:34:60 -0500"),
1468 @"2024-01-10T05:34:59-05:00[-05:00]",
1469 );
1470 }
1471
1472 #[test]
1473 fn ok_parse_obsolete_zone() {
1474 let p = |input| DateTimeParser::new().parse_zoned(input).unwrap();
1475
1476 insta::assert_debug_snapshot!(
1477 p("Wed, 10 Jan 2024 05:34:45 EST"),
1478 @"2024-01-10T05:34:45-05:00[-05:00]",
1479 );
1480 insta::assert_debug_snapshot!(
1481 p("Wed, 10 Jan 2024 05:34:45 EDT"),
1482 @"2024-01-10T05:34:45-04:00[-04:00]",
1483 );
1484 insta::assert_debug_snapshot!(
1485 p("Wed, 10 Jan 2024 05:34:45 CST"),
1486 @"2024-01-10T05:34:45-06:00[-06:00]",
1487 );
1488 insta::assert_debug_snapshot!(
1489 p("Wed, 10 Jan 2024 05:34:45 CDT"),
1490 @"2024-01-10T05:34:45-05:00[-05:00]",
1491 );
1492 insta::assert_debug_snapshot!(
1493 p("Wed, 10 Jan 2024 05:34:45 mst"),
1494 @"2024-01-10T05:34:45-07:00[-07:00]",
1495 );
1496 insta::assert_debug_snapshot!(
1497 p("Wed, 10 Jan 2024 05:34:45 mdt"),
1498 @"2024-01-10T05:34:45-06:00[-06:00]",
1499 );
1500 insta::assert_debug_snapshot!(
1501 p("Wed, 10 Jan 2024 05:34:45 pst"),
1502 @"2024-01-10T05:34:45-08:00[-08:00]",
1503 );
1504 insta::assert_debug_snapshot!(
1505 p("Wed, 10 Jan 2024 05:34:45 pdt"),
1506 @"2024-01-10T05:34:45-07:00[-07:00]",
1507 );
1508
1509 // Various things that mean UTC.
1510 insta::assert_debug_snapshot!(
1511 p("Wed, 10 Jan 2024 05:34:45 UT"),
1512 @"2024-01-10T05:34:45+00:00[UTC]",
1513 );
1514 insta::assert_debug_snapshot!(
1515 p("Wed, 10 Jan 2024 05:34:45 Z"),
1516 @"2024-01-10T05:34:45+00:00[UTC]",
1517 );
1518 insta::assert_debug_snapshot!(
1519 p("Wed, 10 Jan 2024 05:34:45 gmt"),
1520 @"2024-01-10T05:34:45+00:00[UTC]",
1521 );
1522
1523 // Even things that are unrecognized just get treated as having
1524 // an offset of 0.
1525 insta::assert_debug_snapshot!(
1526 p("Wed, 10 Jan 2024 05:34:45 XXX"),
1527 @"2024-01-10T05:34:45+00:00[UTC]",
1528 );
1529 insta::assert_debug_snapshot!(
1530 p("Wed, 10 Jan 2024 05:34:45 ABCDE"),
1531 @"2024-01-10T05:34:45+00:00[UTC]",
1532 );
1533 insta::assert_debug_snapshot!(
1534 p("Wed, 10 Jan 2024 05:34:45 FUCK"),
1535 @"2024-01-10T05:34:45+00:00[UTC]",
1536 );
1537 }
1538
1539 // whyyyyyyyyyyyyy
1540 #[test]
1541 fn ok_parse_comment() {
1542 let p = |input| DateTimeParser::new().parse_zoned(input).unwrap();
1543
1544 insta::assert_debug_snapshot!(
1545 p("Wed, 10 Jan 2024 05:34:45 -0500 (wat)"),
1546 @"2024-01-10T05:34:45-05:00[-05:00]",
1547 );
1548 insta::assert_debug_snapshot!(
1549 p("Wed, 10 Jan 2024 05:34:45 -0500 (w(a)t)"),
1550 @"2024-01-10T05:34:45-05:00[-05:00]",
1551 );
1552 insta::assert_debug_snapshot!(
1553 p(r"Wed, 10 Jan 2024 05:34:45 -0500 (w\(a\)t)"),
1554 @"2024-01-10T05:34:45-05:00[-05:00]",
1555 );
1556 }
1557
1558 #[test]
1559 fn ok_parse_whitespace() {
1560 let p = |input| DateTimeParser::new().parse_zoned(input).unwrap();
1561
1562 insta::assert_debug_snapshot!(
1563 p("Wed, 10 \t Jan \n\r\n\n 2024 05:34:45 -0500"),
1564 @"2024-01-10T05:34:45-05:00[-05:00]",
1565 );
1566 insta::assert_debug_snapshot!(
1567 p("Wed, 10 Jan 2024 05:34:45 -0500 "),
1568 @"2024-01-10T05:34:45-05:00[-05:00]",
1569 );
1570 // Whitespace around the comma is optional
1571 insta::assert_debug_snapshot!(
1572 p("Wed,10 Jan 2024 05:34:45 -0500"),
1573 @"2024-01-10T05:34:45-05:00[-05:00]",
1574 );
1575 insta::assert_debug_snapshot!(
1576 p("Wed , 10 Jan 2024 05:34:45 -0500"),
1577 @"2024-01-10T05:34:45-05:00[-05:00]",
1578 );
1579 insta::assert_debug_snapshot!(
1580 p("Wed ,10 Jan 2024 05:34:45 -0500"),
1581 @"2024-01-10T05:34:45-05:00[-05:00]",
1582 );
1583 // Whitespace is allowed around the time components
1584 insta::assert_debug_snapshot!(
1585 p("Wed, 10 Jan 2024 05 :34: 45 -0500"),
1586 @"2024-01-10T05:34:45-05:00[-05:00]",
1587 );
1588 insta::assert_debug_snapshot!(
1589 p("Wed, 10 Jan 2024 05: 34 :45 -0500"),
1590 @"2024-01-10T05:34:45-05:00[-05:00]",
1591 );
1592 insta::assert_debug_snapshot!(
1593 p("Wed, 10 Jan 2024 05 : 34 : 45 -0500"),
1594 @"2024-01-10T05:34:45-05:00[-05:00]",
1595 );
1596 }
1597
1598 #[test]
1599 fn err_parse_invalid() {
1600 let p = |input| {
1601 DateTimeParser::new().parse_zoned(input).unwrap_err().to_string()
1602 };
1603
1604 insta::assert_snapshot!(
1605 p("Thu, 10 Jan 2024 05:34:45 -0500"),
1606 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: found parsed weekday of `Thursday`, but parsed datetime has weekday `Wednesday`",
1607 );
1608 insta::assert_snapshot!(
1609 p("Wed, 29 Feb 2023 05:34:45 -0500"),
1610 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: invalid date: parameter 'day' for `2023-02` is invalid, must be in range `1..=28`",
1611 );
1612 insta::assert_snapshot!(
1613 p("Mon, 31 Jun 2024 05:34:45 -0500"),
1614 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: invalid date: parameter 'day' for `2024-06` is invalid, must be in range `1..=30`",
1615 );
1616 insta::assert_snapshot!(
1617 p("Tue, 32 Jun 2024 05:34:45 -0500"),
1618 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: failed to parse day: parameter 'day' is not in the required range of 1..=31",
1619 );
1620 insta::assert_snapshot!(
1621 p("Sun, 30 Jun 2024 24:00:00 -0500"),
1622 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: failed to parse hour (expects a two digit integer): parameter 'hour' is not in the required range of 0..=23",
1623 );
1624 // No whitespace after time
1625 insta::assert_snapshot!(
1626 p("Wed, 10 Jan 2024 05:34MST"),
1627 @r###"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none"###,
1628 );
1629 }
1630
1631 #[test]
1632 fn err_parse_incomplete() {
1633 let p = |input| {
1634 DateTimeParser::new().parse_zoned(input).unwrap_err().to_string()
1635 };
1636
1637 insta::assert_snapshot!(
1638 p(""),
1639 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected RFC 2822 datetime, but got empty string",
1640 );
1641 insta::assert_snapshot!(
1642 p(" "),
1643 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected RFC 2822 datetime, but got empty string after trimming leading whitespace",
1644 );
1645 insta::assert_snapshot!(
1646 p("Wat"),
1647 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected day at beginning of RFC 2822 datetime since first non-whitespace byte, `W`, is not a digit, but given string is too short (length is 3)",
1648 );
1649 insta::assert_snapshot!(
1650 p("Wed"),
1651 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected day at beginning of RFC 2822 datetime since first non-whitespace byte, `W`, is not a digit, but given string is too short (length is 3)",
1652 );
1653 insta::assert_snapshot!(
1654 p("Wed "),
1655 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected comma after parsed weekday in RFC 2822 datetime, but found end of input instead",
1656 );
1657 insta::assert_snapshot!(
1658 p("Wed ,"),
1659 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected numeric day, but found end of input",
1660 );
1661 insta::assert_snapshot!(
1662 p("Wed , "),
1663 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected numeric day, but found end of input",
1664 );
1665 insta::assert_snapshot!(
1666 p("Wat, "),
1667 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected day at beginning of RFC 2822 datetime since first non-whitespace byte, `W`, is not a digit, but did not recognize a valid weekday abbreviation",
1668 );
1669 insta::assert_snapshot!(
1670 p("Wed, "),
1671 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected numeric day, but found end of input",
1672 );
1673 insta::assert_snapshot!(
1674 p("Wed, 1"),
1675 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing day: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1676 );
1677 insta::assert_snapshot!(
1678 p("Wed, 10"),
1679 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing day: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1680 );
1681 insta::assert_snapshot!(
1682 p("Wed, 10 J"),
1683 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected abbreviated month name, but remaining input is too short (remaining bytes is 1)",
1684 );
1685 insta::assert_snapshot!(
1686 p("Wed, 10 Wat"),
1687 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected abbreviated month name, but did not recognize a valid abbreviated month name",
1688 );
1689 insta::assert_snapshot!(
1690 p("Wed, 10 Jan"),
1691 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing abbreviated month name: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1692 );
1693 insta::assert_snapshot!(
1694 p("Wed, 10 Jan 2"),
1695 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected at least two ASCII digits for parsing a year, but only found 1",
1696 );
1697 insta::assert_snapshot!(
1698 p("Wed, 10 Jan 2024"),
1699 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing year: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1700 );
1701 insta::assert_snapshot!(
1702 p("Wed, 10 Jan 2024 05"),
1703 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected time separator of `:`, but found end of input",
1704 );
1705 insta::assert_snapshot!(
1706 p("Wed, 10 Jan 2024 053"),
1707 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected time separator of `:`, but found `3`",
1708 );
1709 insta::assert_snapshot!(
1710 p("Wed, 10 Jan 2024 05:34"),
1711 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1712 );
1713 insta::assert_snapshot!(
1714 p("Wed, 10 Jan 2024 05:34:"),
1715 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected two digit second, but found end of input",
1716 );
1717 insta::assert_snapshot!(
1718 p("Wed, 10 Jan 2024 05:34:45"),
1719 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected whitespace after parsing time: expected at least one whitespace character (space or tab), but found none",
1720 );
1721 insta::assert_snapshot!(
1722 p("Wed, 10 Jan 2024 05:34:45 J"),
1723 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: expected obsolete RFC 2822 time zone abbreviation, but did not recognize a valid abbreviation",
1724 );
1725 }
1726
1727 #[test]
1728 fn err_parse_comment() {
1729 let p = |input| {
1730 DateTimeParser::new().parse_zoned(input).unwrap_err().to_string()
1731 };
1732
1733 insta::assert_snapshot!(
1734 p(r"Wed, 10 Jan 2024 05:34:45 -0500 (wa)t)"),
1735 @r###"parsed value '2024-01-10T05:34:45-05:00[-05:00]', but unparsed input "t)" remains (expected no unparsed input)"###,
1736 );
1737 insta::assert_snapshot!(
1738 p(r"Wed, 10 Jan 2024 05:34:45 -0500 (wa(t)"),
1739 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: found opening parenthesis in comment with no matching closing parenthesis",
1740 );
1741 insta::assert_snapshot!(
1742 p(r"Wed, 10 Jan 2024 05:34:45 -0500 (w"),
1743 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: found opening parenthesis in comment with no matching closing parenthesis",
1744 );
1745 insta::assert_snapshot!(
1746 p(r"Wed, 10 Jan 2024 05:34:45 -0500 ("),
1747 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: found opening parenthesis in comment with no matching closing parenthesis",
1748 );
1749 insta::assert_snapshot!(
1750 p(r"Wed, 10 Jan 2024 05:34:45 -0500 ( "),
1751 @"failed to parse RFC 2822 datetime into Jiff zoned datetime: found opening parenthesis in comment with no matching closing parenthesis",
1752 );
1753 }
1754
1755 #[test]
1756 fn ok_print_zoned() {
1757 if crate::tz::db().is_definitively_empty() {
1758 return;
1759 }
1760
1761 let p = |zdt: &Zoned| -> String {
1762 let mut buf = String::new();
1763 DateTimePrinter::new().print_zoned(&zdt, &mut buf).unwrap();
1764 buf
1765 };
1766
1767 let zdt = date(2024, 1, 10)
1768 .at(5, 34, 45, 0)
1769 .in_tz("America/New_York")
1770 .unwrap();
1771 insta::assert_snapshot!(p(&zdt), @"Wed, 10 Jan 2024 05:34:45 -0500");
1772
1773 let zdt = date(2024, 2, 5)
1774 .at(5, 34, 45, 0)
1775 .in_tz("America/New_York")
1776 .unwrap();
1777 insta::assert_snapshot!(p(&zdt), @"Mon, 5 Feb 2024 05:34:45 -0500");
1778
1779 let zdt = date(2024, 7, 31)
1780 .at(5, 34, 45, 0)
1781 .in_tz("America/New_York")
1782 .unwrap();
1783 insta::assert_snapshot!(p(&zdt), @"Wed, 31 Jul 2024 05:34:45 -0400");
1784
1785 let zdt = date(2024, 3, 5).at(5, 34, 45, 0).in_tz("UTC").unwrap();
1786 // Notice that this prints a +0000 offset.
1787 // But when printing a Timestamp, a -0000 offset is used.
1788 // This is because in the case of Timestamp, the "true"
1789 // offset is not known.
1790 insta::assert_snapshot!(p(&zdt), @"Tue, 5 Mar 2024 05:34:45 +0000");
1791 }
1792
1793 #[test]
1794 fn ok_print_timestamp() {
1795 if crate::tz::db().is_definitively_empty() {
1796 return;
1797 }
1798
1799 let p = |ts: Timestamp| -> String {
1800 let mut buf = String::new();
1801 DateTimePrinter::new().print_timestamp(&ts, &mut buf).unwrap();
1802 buf
1803 };
1804
1805 let ts = date(2024, 1, 10)
1806 .at(5, 34, 45, 0)
1807 .in_tz("America/New_York")
1808 .unwrap()
1809 .timestamp();
1810 insta::assert_snapshot!(p(ts), @"Wed, 10 Jan 2024 10:34:45 -0000");
1811
1812 let ts = date(2024, 2, 5)
1813 .at(5, 34, 45, 0)
1814 .in_tz("America/New_York")
1815 .unwrap()
1816 .timestamp();
1817 insta::assert_snapshot!(p(ts), @"Mon, 5 Feb 2024 10:34:45 -0000");
1818
1819 let ts = date(2024, 7, 31)
1820 .at(5, 34, 45, 0)
1821 .in_tz("America/New_York")
1822 .unwrap()
1823 .timestamp();
1824 insta::assert_snapshot!(p(ts), @"Wed, 31 Jul 2024 09:34:45 -0000");
1825
1826 let ts = date(2024, 3, 5)
1827 .at(5, 34, 45, 0)
1828 .in_tz("UTC")
1829 .unwrap()
1830 .timestamp();
1831 // Notice that this prints a +0000 offset.
1832 // But when printing a Timestamp, a -0000 offset is used.
1833 // This is because in the case of Timestamp, the "true"
1834 // offset is not known.
1835 insta::assert_snapshot!(p(ts), @"Tue, 5 Mar 2024 05:34:45 -0000");
1836 }
1837
1838 #[test]
1839 fn ok_minimum_offset_roundtrip() {
1840 let zdt = date(2025, 12, 25)
1841 .at(17, 0, 0, 0)
1842 .to_zoned(TimeZone::fixed(Offset::MIN))
1843 .unwrap();
1844 let string = DateTimePrinter::new().zoned_to_string(&zdt).unwrap();
1845 assert_eq!(string, "Thu, 25 Dec 2025 17:00:00 -2559");
1846
1847 let got: Zoned = DateTimeParser::new().parse_zoned(&string).unwrap();
1848 // Since we started with a zoned datetime with a minimal offset
1849 // (to second precision) and RFC 2822 only supports minute precision
1850 // in time zone offsets, printing the zoned datetime rounds the offset.
1851 // But this would normally result in an offset beyond Jiff's limits,
1852 // so in this case, the offset truncates to the minimum supported
1853 // value by both Jiff and RFC 2822. That's what we test for here.
1854 let expected = date(2025, 12, 25)
1855 .at(17, 0, 0, 0)
1856 .to_zoned(TimeZone::fixed(-Offset::hms(25, 59, 0)))
1857 .unwrap();
1858 assert_eq!(expected, got);
1859 }
1860
1861 #[test]
1862 fn ok_maximum_offset_roundtrip() {
1863 let zdt = date(2025, 12, 25)
1864 .at(17, 0, 0, 0)
1865 .to_zoned(TimeZone::fixed(Offset::MAX))
1866 .unwrap();
1867 let string = DateTimePrinter::new().zoned_to_string(&zdt).unwrap();
1868 assert_eq!(string, "Thu, 25 Dec 2025 17:00:00 +2559");
1869
1870 let got: Zoned = DateTimeParser::new().parse_zoned(&string).unwrap();
1871 // Since we started with a zoned datetime with a maximal offset
1872 // (to second precision) and RFC 2822 only supports minute precision
1873 // in time zone offsets, printing the zoned datetime rounds the offset.
1874 // But this would normally result in an offset beyond Jiff's limits,
1875 // so in this case, the offset truncates to the maximum supported
1876 // value by both Jiff and RFC 2822. That's what we test for here.
1877 let expected = date(2025, 12, 25)
1878 .at(17, 0, 0, 0)
1879 .to_zoned(TimeZone::fixed(Offset::hms(25, 59, 0)))
1880 .unwrap();
1881 assert_eq!(expected, got);
1882 }
1883
1884 #[test]
1885 fn ok_print_rfc9110_timestamp() {
1886 if crate::tz::db().is_definitively_empty() {
1887 return;
1888 }
1889
1890 let p = |ts: Timestamp| -> String {
1891 let mut buf = String::new();
1892 DateTimePrinter::new()
1893 .print_timestamp_rfc9110(&ts, &mut buf)
1894 .unwrap();
1895 buf
1896 };
1897
1898 let ts = date(2024, 1, 10)
1899 .at(5, 34, 45, 0)
1900 .in_tz("America/New_York")
1901 .unwrap()
1902 .timestamp();
1903 insta::assert_snapshot!(p(ts), @"Wed, 10 Jan 2024 10:34:45 GMT");
1904
1905 let ts = date(2024, 2, 5)
1906 .at(5, 34, 45, 0)
1907 .in_tz("America/New_York")
1908 .unwrap()
1909 .timestamp();
1910 insta::assert_snapshot!(p(ts), @"Mon, 05 Feb 2024 10:34:45 GMT");
1911
1912 let ts = date(2024, 7, 31)
1913 .at(5, 34, 45, 0)
1914 .in_tz("America/New_York")
1915 .unwrap()
1916 .timestamp();
1917 insta::assert_snapshot!(p(ts), @"Wed, 31 Jul 2024 09:34:45 GMT");
1918
1919 let ts = date(2024, 3, 5)
1920 .at(5, 34, 45, 0)
1921 .in_tz("UTC")
1922 .unwrap()
1923 .timestamp();
1924 // Notice that this prints a +0000 offset.
1925 // But when printing a Timestamp, a -0000 offset is used.
1926 // This is because in the case of Timestamp, the "true"
1927 // offset is not known.
1928 insta::assert_snapshot!(p(ts), @"Tue, 05 Mar 2024 05:34:45 GMT");
1929 }
1930
1931 #[test]
1932 fn err_print_zoned() {
1933 if crate::tz::db().is_definitively_empty() {
1934 return;
1935 }
1936
1937 let p = |zdt: &Zoned| -> String {
1938 let mut buf = String::new();
1939 DateTimePrinter::new()
1940 .print_zoned(&zdt, &mut buf)
1941 .unwrap_err()
1942 .to_string()
1943 };
1944
1945 let zdt = date(-1, 1, 10)
1946 .at(5, 34, 45, 0)
1947 .in_tz("America/New_York")
1948 .unwrap();
1949 insta::assert_snapshot!(p(&zdt), @"datetime has negative year, which cannot be formatted with RFC 2822");
1950 }
1951
1952 #[test]
1953 fn err_print_timestamp() {
1954 if crate::tz::db().is_definitively_empty() {
1955 return;
1956 }
1957
1958 let p = |ts: Timestamp| -> String {
1959 let mut buf = String::new();
1960 DateTimePrinter::new()
1961 .print_timestamp(&ts, &mut buf)
1962 .unwrap_err()
1963 .to_string()
1964 };
1965
1966 let ts = date(-1, 1, 10)
1967 .at(5, 34, 45, 0)
1968 .in_tz("America/New_York")
1969 .unwrap()
1970 .timestamp();
1971 insta::assert_snapshot!(p(ts), @"datetime has negative year, which cannot be formatted with RFC 2822");
1972 }
1973}