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