icu_time/
ixdtf.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use crate::{
6    zone::{
7        iana::IanaParserBorrowed, models, InvalidOffsetError, UtcOffset,
8        VariantOffsetsCalculatorBorrowed,
9    },
10    DateTime, Time, TimeZone, TimeZoneInfo, ZonedDateTime,
11};
12use core::str::FromStr;
13use icu_calendar::{AnyCalendarKind, AsCalendar, Date, DateError, Iso, RangeError};
14use icu_locale_core::subtags::subtag;
15use ixdtf::{
16    parsers::{
17        records::{
18            DateRecord, IxdtfParseRecord, TimeRecord, TimeZoneAnnotation, TimeZoneRecord,
19            UtcOffsetRecord, UtcOffsetRecordOrZ,
20        },
21        IxdtfParser,
22    },
23    ParseError as Rfc9557ParseError,
24};
25
26/// The error type for parsing RFC 9557 strings.
27#[derive(Debug, PartialEq, displaydoc::Display)]
28#[non_exhaustive]
29pub enum ParseError {
30    /// Syntax error.
31    #[displaydoc("Syntax error in the RFC 9557 string: {0}")]
32    Syntax(Rfc9557ParseError),
33    /// Parsed record is out of valid date range.
34    #[displaydoc("Value out of range: {0}")]
35    Range(RangeError),
36    /// Parsed date and time records were not a valid ISO date.
37    #[displaydoc("Parsed date and time records were not a valid ISO date: {0}")]
38    Date(DateError),
39    /// There were missing fields required to parse component.
40    MissingFields,
41    /// There were two offsets provided that were not consistent with each other.
42    InconsistentTimeUtcOffsets,
43    /// There was an invalid Offset.
44    InvalidOffsetError,
45    /// Parsed fractional digits had excessive precision beyond nanosecond.
46    ExcessivePrecision,
47    /// The set of time zone fields was not expected for the given type.
48    /// For example, if a named time zone was present with offset-only parsing,
49    /// or an offset was present with named-time-zone-only parsing.
50    #[displaydoc("The set of time zone fields was not expected for the given type")]
51    MismatchedTimeZoneFields,
52    /// An unknown calendar was provided.
53    UnknownCalendar,
54    /// Expected a different calendar.
55    #[displaydoc("Expected calendar {0} but found calendar {1}")]
56    MismatchedCalendar(AnyCalendarKind, AnyCalendarKind),
57    /// A timezone calculation is required to interpret this string, which is not supported.
58    ///
59    /// # Example
60    /// ```
61    /// use icu::calendar::Iso;
62    /// use icu::time::{ZonedDateTime, ParseError, zone::IanaParser};
63    ///
64    /// // This timestamp is in UTC, and requires a time zone calculation in order to display a Zurich time.
65    /// assert_eq!(
66    ///     ZonedDateTime::try_lenient_from_str("2024-08-12T12:32:00Z[Europe/Zurich]", Iso, IanaParser::new()).unwrap_err(),
67    ///     ParseError::RequiresCalculation,
68    /// );
69    ///
70    /// // These timestamps are in local time
71    /// ZonedDateTime::try_lenient_from_str("2024-08-12T14:32:00+02:00[Europe/Zurich]", Iso, IanaParser::new()).unwrap();
72    /// ZonedDateTime::try_lenient_from_str("2024-08-12T14:32:00[Europe/Zurich]", Iso, IanaParser::new()).unwrap();
73    /// ```
74    #[displaydoc(
75        "A timezone calculation is required to interpret this string, which is not supported"
76    )]
77    RequiresCalculation,
78}
79
80impl core::error::Error for ParseError {}
81
82impl From<Rfc9557ParseError> for ParseError {
83    fn from(value: Rfc9557ParseError) -> Self {
84        Self::Syntax(value)
85    }
86}
87
88impl From<RangeError> for ParseError {
89    fn from(value: RangeError) -> Self {
90        Self::Range(value)
91    }
92}
93
94impl From<DateError> for ParseError {
95    fn from(value: DateError) -> Self {
96        Self::Date(value)
97    }
98}
99
100impl From<InvalidOffsetError> for ParseError {
101    fn from(_: InvalidOffsetError) -> Self {
102        Self::InvalidOffsetError
103    }
104}
105
106impl From<icu_calendar::ParseError> for ParseError {
107    fn from(value: icu_calendar::ParseError) -> Self {
108        match value {
109            icu_calendar::ParseError::MissingFields => Self::MissingFields,
110            icu_calendar::ParseError::Range(r) => Self::Range(r),
111            icu_calendar::ParseError::Syntax(s) => Self::Syntax(s),
112            icu_calendar::ParseError::UnknownCalendar => Self::UnknownCalendar,
113            _ => unreachable!(),
114        }
115    }
116}
117
118impl UtcOffset {
119    fn try_from_utc_offset_record(record: UtcOffsetRecord) -> Result<Self, ParseError> {
120        let hour_seconds = i32::from(record.hour()) * 3600;
121        let minute_seconds = i32::from(record.minute()) * 60;
122        Self::try_from_seconds(
123            i32::from(record.sign() as i8)
124                * (hour_seconds + minute_seconds + i32::from(record.second().unwrap_or(0))),
125        )
126        .map_err(Into::into)
127    }
128}
129
130struct Intermediate<'a> {
131    offset: Option<UtcOffsetRecord>,
132    is_z: bool,
133    iana_identifier: Option<&'a [u8]>,
134    date: DateRecord,
135    time: TimeRecord,
136}
137
138impl<'a> Intermediate<'a> {
139    fn try_from_ixdtf_record(ixdtf_record: &'a IxdtfParseRecord) -> Result<Self, ParseError> {
140        let (offset, is_z, iana_identifier) = match ixdtf_record {
141            // empty
142            IxdtfParseRecord {
143                offset: None,
144                tz: None,
145                ..
146            } => (None, false, None),
147            // -0800
148            IxdtfParseRecord {
149                offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
150                tz: None,
151                ..
152            } => (Some(*offset), false, None),
153            // Z
154            IxdtfParseRecord {
155                offset: Some(UtcOffsetRecordOrZ::Z),
156                tz: None,
157                ..
158            } => (None, true, None),
159            // [-0800]
160            IxdtfParseRecord {
161                offset: None,
162                tz:
163                    Some(TimeZoneAnnotation {
164                        tz: TimeZoneRecord::Offset(offset),
165                        ..
166                    }),
167                ..
168            } => (Some(UtcOffsetRecord::MinutePrecision(*offset)), false, None),
169            // -0800[-0800]
170            IxdtfParseRecord {
171                offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
172                tz:
173                    Some(TimeZoneAnnotation {
174                        tz: TimeZoneRecord::Offset(offset1),
175                        ..
176                    }),
177                ..
178            } => {
179                let annotation_offset = UtcOffsetRecord::MinutePrecision(*offset1);
180                if offset != &annotation_offset {
181                    return Err(ParseError::InconsistentTimeUtcOffsets);
182                }
183                (Some(*offset), false, None)
184            }
185            // -0800[America/Los_Angeles]
186            IxdtfParseRecord {
187                offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
188                tz:
189                    Some(TimeZoneAnnotation {
190                        tz: TimeZoneRecord::Name(iana_identifier),
191                        ..
192                    }),
193                ..
194            } => (Some(*offset), false, Some(*iana_identifier)),
195            // Z[-0800]
196            IxdtfParseRecord {
197                offset: Some(UtcOffsetRecordOrZ::Z),
198                tz:
199                    Some(TimeZoneAnnotation {
200                        tz: TimeZoneRecord::Offset(offset),
201                        ..
202                    }),
203                ..
204            } => (Some(UtcOffsetRecord::MinutePrecision(*offset)), true, None),
205            // Z[America/Los_Angeles]
206            IxdtfParseRecord {
207                offset: Some(UtcOffsetRecordOrZ::Z),
208                tz:
209                    Some(TimeZoneAnnotation {
210                        tz: TimeZoneRecord::Name(iana_identifier),
211                        ..
212                    }),
213                ..
214            } => (None, true, Some(*iana_identifier)),
215            // [America/Los_Angeles]
216            IxdtfParseRecord {
217                offset: None,
218                tz:
219                    Some(TimeZoneAnnotation {
220                        tz: TimeZoneRecord::Name(iana_identifier),
221                        ..
222                    }),
223                ..
224            } => (None, false, Some(*iana_identifier)),
225            // non_exhaustive match: maybe something like [u-tz=uslax] in the future
226            IxdtfParseRecord {
227                tz: Some(TimeZoneAnnotation { tz, .. }),
228                ..
229            } => {
230                debug_assert!(false, "unexpected TimeZoneRecord: {tz:?}");
231                (None, false, None)
232            }
233        };
234        let IxdtfParseRecord {
235            date: Some(date),
236            time: Some(time),
237            ..
238        } = *ixdtf_record
239        else {
240            // Date or time was missing
241            return Err(ParseError::MismatchedTimeZoneFields);
242        };
243        Ok(Self {
244            offset,
245            is_z,
246            iana_identifier,
247            date,
248            time,
249        })
250    }
251
252    fn offset_only(self) -> Result<UtcOffset, ParseError> {
253        let None = self.iana_identifier else {
254            return Err(ParseError::MismatchedTimeZoneFields);
255        };
256        if self.is_z {
257            if let Some(offset) = self.offset {
258                if offset != UtcOffsetRecord::zero() {
259                    return Err(ParseError::RequiresCalculation);
260                }
261            }
262            return Ok(UtcOffset::zero());
263        }
264        let Some(offset) = self.offset else {
265            return Err(ParseError::MismatchedTimeZoneFields);
266        };
267        UtcOffset::try_from_utc_offset_record(offset)
268    }
269
270    fn location_only(
271        self,
272        iana_parser: IanaParserBorrowed<'_>,
273    ) -> Result<TimeZoneInfo<models::AtTime>, ParseError> {
274        let None = self.offset else {
275            return Err(ParseError::MismatchedTimeZoneFields);
276        };
277        let Some(iana_identifier) = self.iana_identifier else {
278            if self.is_z {
279                return Err(ParseError::RequiresCalculation);
280            }
281            return Err(ParseError::MismatchedTimeZoneFields);
282        };
283        let id = iana_parser.parse_from_utf8(iana_identifier);
284        let date = Date::<Iso>::try_new_iso(self.date.year, self.date.month, self.date.day)?;
285        let time = Time::try_from_time_record(&self.time)?;
286        let offset = match id.as_str() {
287            "utc" | "gmt" => Some(UtcOffset::zero()),
288            _ => None,
289        };
290        Ok(id
291            .with_offset(offset)
292            .at_date_time_iso(DateTime { date, time }))
293    }
294
295    fn lenient(
296        self,
297        iana_parser: IanaParserBorrowed<'_>,
298    ) -> Result<TimeZoneInfo<models::AtTime>, ParseError> {
299        let id = match self.iana_identifier {
300            Some(iana_identifier) => {
301                if self.is_z {
302                    return Err(ParseError::RequiresCalculation);
303                }
304                iana_parser.parse_from_utf8(iana_identifier)
305            }
306            None if self.is_z => TimeZone(subtag!("utc")),
307            None => TimeZone::UNKNOWN,
308        };
309        let offset = match self.offset {
310            Some(offset) => {
311                if self.is_z && offset != UtcOffsetRecord::zero() {
312                    return Err(ParseError::RequiresCalculation);
313                }
314                Some(UtcOffset::try_from_utc_offset_record(offset)?)
315            }
316            None => match id.as_str() {
317                "utc" | "gmt" => Some(UtcOffset::zero()),
318                _ if self.is_z => Some(UtcOffset::zero()),
319                _ => None,
320            },
321        };
322        let date = Date::<Iso>::try_new_iso(self.date.year, self.date.month, self.date.day)?;
323        let time = Time::try_from_time_record(&self.time)?;
324        Ok(id
325            .with_offset(offset)
326            .at_date_time_iso(DateTime { date, time }))
327    }
328
329    fn full(
330        self,
331        iana_parser: IanaParserBorrowed<'_>,
332        offset_calculator: VariantOffsetsCalculatorBorrowed,
333    ) -> Result<TimeZoneInfo<models::Full>, ParseError> {
334        let Some(offset) = self.offset else {
335            return Err(ParseError::MismatchedTimeZoneFields);
336        };
337        let Some(iana_identifier) = self.iana_identifier else {
338            return Err(ParseError::MismatchedTimeZoneFields);
339        };
340        let time_zone_id = iana_parser.parse_from_utf8(iana_identifier);
341        let date = Date::try_new_iso(self.date.year, self.date.month, self.date.day)?;
342        let time = Time::try_from_time_record(&self.time)?;
343        let offset = UtcOffset::try_from_utc_offset_record(offset)?;
344        Ok(time_zone_id
345            .with_offset(Some(offset))
346            .at_date_time_iso(DateTime { date, time })
347            .infer_variant(offset_calculator))
348    }
349}
350
351impl<A: AsCalendar> ZonedDateTime<A, UtcOffset> {
352    /// Create a [`ZonedDateTime`] in any calendar from an RFC 9557 string.
353    ///
354    /// Returns an error if the string has a calendar annotation that does not
355    /// match the calendar argument, unless the argument is [`Iso`].
356    ///
357    /// This function is "strict": the string should have only an offset and no named time zone.
358    pub fn try_offset_only_from_str(rfc_9557_str: &str, calendar: A) -> Result<Self, ParseError> {
359        Self::try_offset_only_from_utf8(rfc_9557_str.as_bytes(), calendar)
360    }
361
362    /// Create a [`ZonedDateTime`] in any calendar from RFC 9557 syntax UTF-8 bytes.
363    ///
364    /// See [`Self:try_offset_only_from_str`](Self::try_offset_only_from_str).
365    pub fn try_offset_only_from_utf8(rfc_9557_str: &[u8], calendar: A) -> Result<Self, ParseError> {
366        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
367        let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
368        let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
369        let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?.offset_only()?;
370        Ok(ZonedDateTime { date, time, zone })
371    }
372}
373
374impl<A: AsCalendar> ZonedDateTime<A, TimeZoneInfo<models::AtTime>> {
375    /// Create a [`ZonedDateTime`] in any calendar from an RFC 9557 string.
376    ///
377    /// Returns an error if the string has a calendar annotation that does not
378    /// match the calendar argument, unless the argument is [`Iso`].
379    ///
380    /// This function is "strict": the string should have only a named time zone and no offset.
381    pub fn try_location_only_from_str(
382        rfc_9557_str: &str,
383        calendar: A,
384        iana_parser: IanaParserBorrowed,
385    ) -> Result<Self, ParseError> {
386        Self::try_location_only_from_utf8(rfc_9557_str.as_bytes(), calendar, iana_parser)
387    }
388
389    /// Create a [`ZonedDateTime`] in any calendar from RFC 9557 UTF-8 bytes.
390    ///
391    /// See [`Self::try_location_only_from_str`].
392    pub fn try_location_only_from_utf8(
393        rfc_9557_str: &[u8],
394        calendar: A,
395        iana_parser: IanaParserBorrowed,
396    ) -> Result<Self, ParseError> {
397        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
398        let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
399        let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
400        let zone =
401            Intermediate::try_from_ixdtf_record(&ixdtf_record)?.location_only(iana_parser)?;
402        Ok(ZonedDateTime { date, time, zone })
403    }
404
405    /// Create a [`ZonedDateTime`] in any calendar from an RFC 9557 string.
406    ///
407    /// Returns an error if the string has a calendar annotation that does not
408    /// match the calendar argument, unless the argument is [`Iso`].
409    ///
410    /// This function is "lenient": the string can have an offset, and named time zone, both, or
411    /// neither. If the named time zone is missing, it is returned as Etc/Unknown.
412    ///
413    /// The zone variant is _not_ calculated with this function. If you need it, use
414    /// [`Self::try_full_from_str`].
415    pub fn try_lenient_from_str(
416        rfc_9557_str: &str,
417        calendar: A,
418        iana_parser: IanaParserBorrowed,
419    ) -> Result<Self, ParseError> {
420        Self::try_lenient_from_utf8(rfc_9557_str.as_bytes(), calendar, iana_parser)
421    }
422
423    /// Create a [`ZonedDateTime`] in any calendar from RFC 9557 UTF-8 bytes.
424    ///
425    /// See [`Self::try_lenient_from_str`].
426    pub fn try_lenient_from_utf8(
427        rfc_9557_str: &[u8],
428        calendar: A,
429        iana_parser: IanaParserBorrowed,
430    ) -> Result<Self, ParseError> {
431        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
432        let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
433        let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
434        let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?.lenient(iana_parser)?;
435        Ok(ZonedDateTime { date, time, zone })
436    }
437}
438
439impl<A: AsCalendar> ZonedDateTime<A, TimeZoneInfo<models::Full>> {
440    /// Create a [`ZonedDateTime`] in any calendar from an RFC 9557 string.
441    ///
442    /// Returns an error if the string has a calendar annotation that does not
443    /// match the calendar argument, unless the argument is [`Iso`].
444    ///
445    /// The string should have both an offset and a named time zone.
446    ///
447    /// For more information on RFC 9557, see the [`ixdtf`] crate.
448    ///
449    /// # Examples
450    ///
451    /// Basic usage:
452    ///
453    /// ```
454    /// use icu::calendar::cal::Hebrew;
455    /// use icu::locale::subtags::subtag;
456    /// use icu::time::{
457    ///     zone::{
458    ///         IanaParser, TimeZoneVariant, UtcOffset, VariantOffsetsCalculator,
459    ///     },
460    ///     TimeZone, TimeZoneInfo, ZonedDateTime,
461    /// };
462    ///
463    /// let zoneddatetime = ZonedDateTime::try_full_from_str(
464    ///     "2024-08-08T12:08:19-05:00[America/Chicago][u-ca=hebrew]",
465    ///     Hebrew,
466    ///     IanaParser::new(),
467    ///     VariantOffsetsCalculator::new(),
468    /// )
469    /// .unwrap();
470    ///
471    /// assert_eq!(zoneddatetime.date.extended_year(), 5784);
472    /// assert_eq!(
473    ///     zoneddatetime.date.month().standard_code,
474    ///     icu::calendar::types::MonthCode(tinystr::tinystr!(4, "M11"))
475    /// );
476    /// assert_eq!(zoneddatetime.date.day_of_month().0, 4);
477    ///
478    /// assert_eq!(zoneddatetime.time.hour.number(), 12);
479    /// assert_eq!(zoneddatetime.time.minute.number(), 8);
480    /// assert_eq!(zoneddatetime.time.second.number(), 19);
481    /// assert_eq!(zoneddatetime.time.subsecond.number(), 0);
482    /// assert_eq!(zoneddatetime.zone.id(), TimeZone(subtag!("uschi")));
483    /// assert_eq!(
484    ///     zoneddatetime.zone.offset(),
485    ///     Some(UtcOffset::try_from_seconds(-18000).unwrap())
486    /// );
487    /// assert_eq!(zoneddatetime.zone.variant(), TimeZoneVariant::Daylight);
488    /// let _ = zoneddatetime.zone.zone_name_timestamp();
489    /// ```
490    ///
491    /// An RFC 9557 string can provide a time zone in two parts: the DateTime UTC Offset or the Time Zone
492    /// Annotation. A DateTime UTC Offset is the time offset as laid out by RFC 3339; meanwhile, the Time
493    /// Zone Annotation is the annotation laid out by RFC 9557 and is defined as a UTC offset or IANA Time
494    /// Zone identifier.
495    ///
496    /// ## DateTime UTC Offsets
497    ///
498    /// Below is an example of a time zone from a DateTime UTC Offset. The syntax here is familiar to a RFC 3339
499    /// DateTime string.
500    ///
501    /// ```
502    /// use icu::calendar::Iso;
503    /// use icu::time::{zone::UtcOffset, TimeZoneInfo, ZonedDateTime};
504    ///
505    /// let tz_from_offset = ZonedDateTime::try_offset_only_from_str(
506    ///     "2024-08-08T12:08:19-05:00",
507    ///     Iso,
508    /// )
509    /// .unwrap();
510    ///
511    /// assert_eq!(
512    ///     tz_from_offset.zone,
513    ///     UtcOffset::try_from_seconds(-18000).unwrap()
514    /// );
515    /// ```
516    ///
517    /// ## Time Zone Annotations
518    ///
519    /// Below is an example of a time zone being provided by a time zone annotation.
520    ///
521    /// ```
522    /// use icu::calendar::Iso;
523    /// use icu::locale::subtags::subtag;
524    /// use icu::time::{
525    ///     zone::{IanaParser, TimeZoneVariant, UtcOffset},
526    ///     TimeZone, TimeZoneInfo, ZonedDateTime,
527    /// };
528    ///
529    /// let tz_from_offset_annotation = ZonedDateTime::try_offset_only_from_str(
530    ///     "2024-08-08T12:08:19[-05:00]",
531    ///     Iso,
532    /// )
533    /// .unwrap();
534    /// let tz_from_iana_annotation = ZonedDateTime::try_location_only_from_str(
535    ///     "2024-08-08T12:08:19[America/Chicago]",
536    ///     Iso,
537    ///     IanaParser::new(),
538    /// )
539    /// .unwrap();
540    ///
541    /// assert_eq!(
542    ///     tz_from_offset_annotation.zone,
543    ///     UtcOffset::try_from_seconds(-18000).unwrap()
544    /// );
545    ///
546    /// assert_eq!(
547    ///     tz_from_iana_annotation.zone.id(),
548    ///     TimeZone(subtag!("uschi"))
549    /// );
550    /// assert_eq!(tz_from_iana_annotation.zone.offset(), None);
551    /// ```
552    ///
553    /// ## UTC Offset and time zone annotations.
554    ///
555    /// An RFC 9557 string may contain both a UTC Offset and time zone annotation. This is fine as long as
556    /// the time zone parts can be deemed as inconsistent or unknown consistency.
557    ///
558    /// ### UTC Offset with IANA identifier annotation
559    ///
560    /// In cases where the UTC Offset as well as the IANA identifier are provided, some validity checks are performed.
561    ///
562    /// ```
563    /// use icu::calendar::Iso;
564    /// use icu::time::{TimeZoneInfo, ZonedDateTime, TimeZone, ParseError, zone::{UtcOffset, TimeZoneVariant, IanaParser, VariantOffsetsCalculator}};
565    /// use icu::locale::subtags::subtag;
566    ///
567    /// let consistent_tz_from_both = ZonedDateTime::try_full_from_str("2024-08-08T12:08:19-05:00[America/Chicago]", Iso, IanaParser::new(), VariantOffsetsCalculator::new()).unwrap();
568    ///
569    ///
570    /// assert_eq!(consistent_tz_from_both.zone.id(), TimeZone(subtag!("uschi")));
571    /// assert_eq!(consistent_tz_from_both.zone.offset(), Some(UtcOffset::try_from_seconds(-18000).unwrap()));
572    /// assert_eq!(consistent_tz_from_both.zone.variant(), TimeZoneVariant::Daylight);
573    /// let _ = consistent_tz_from_both.zone.zone_name_timestamp();
574    ///
575    /// // There is no name for America/Los_Angeles at -05:00 (at least in 2024), so either the
576    /// // time zone or the offset are wrong.
577    /// // The only valid way to display this zoned datetime is "GMT-5", so we drop the time zone.
578    /// assert_eq!(
579    ///     ZonedDateTime::try_full_from_str("2024-08-08T12:08:19-05:00[America/Los_Angeles]", Iso, IanaParser::new(), VariantOffsetsCalculator::new())
580    ///     .unwrap().zone.id(),
581    ///     TimeZone::UNKNOWN
582    /// );
583    ///
584    /// // We don't know that America/Los_Angeles didn't use standard time (-08:00) in August, but we have a
585    /// // name for Los Angeles at -8 (Pacific Standard Time), so this parses successfully.
586    /// assert!(
587    ///     ZonedDateTime::try_full_from_str("2024-08-08T12:08:19-08:00[America/Los_Angeles]", Iso, IanaParser::new(), VariantOffsetsCalculator::new()).is_ok()
588    /// );
589    /// ```
590    ///
591    /// ### DateTime UTC offset with UTC Offset annotation.
592    ///
593    /// These annotations must always be consistent as they should be either the same value or are inconsistent.
594    ///
595    /// ```
596    /// use icu::calendar::Iso;
597    /// use icu::time::{
598    ///     zone::UtcOffset, ParseError, TimeZone, TimeZoneInfo, ZonedDateTime,
599    /// };
600    /// use tinystr::tinystr;
601    ///
602    /// let consistent_tz_from_both = ZonedDateTime::try_offset_only_from_str(
603    ///     "2024-08-08T12:08:19-05:00[-05:00]",
604    ///     Iso,
605    /// )
606    /// .unwrap();
607    ///
608    /// assert_eq!(
609    ///     consistent_tz_from_both.zone,
610    ///     UtcOffset::try_from_seconds(-18000).unwrap()
611    /// );
612    ///
613    /// let inconsistent_tz_from_both = ZonedDateTime::try_offset_only_from_str(
614    ///     "2024-08-08T12:08:19-05:00[+05:00]",
615    ///     Iso,
616    /// );
617    ///
618    /// assert!(matches!(
619    ///     inconsistent_tz_from_both,
620    ///     Err(ParseError::InconsistentTimeUtcOffsets)
621    /// ));
622    /// ```
623    pub fn try_full_from_str(
624        rfc_9557_str: &str,
625        calendar: A,
626        iana_parser: IanaParserBorrowed,
627        offset_calculator: VariantOffsetsCalculatorBorrowed,
628    ) -> Result<Self, ParseError> {
629        Self::try_full_from_utf8(
630            rfc_9557_str.as_bytes(),
631            calendar,
632            iana_parser,
633            offset_calculator,
634        )
635    }
636
637    /// Create a [`ZonedDateTime`] in any calendar from RFC 9557 UTF-8 bytes.
638    ///
639    /// See [`Self::try_full_from_str`].
640    pub fn try_full_from_utf8(
641        rfc_9557_str: &[u8],
642        calendar: A,
643        iana_parser: IanaParserBorrowed,
644        offset_calculator: VariantOffsetsCalculatorBorrowed,
645    ) -> Result<Self, ParseError> {
646        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
647        let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
648        let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
649        let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?
650            .full(iana_parser, offset_calculator)?;
651
652        Ok(ZonedDateTime { date, time, zone })
653    }
654}
655
656impl FromStr for DateTime<Iso> {
657    type Err = ParseError;
658    fn from_str(rfc_9557_str: &str) -> Result<Self, Self::Err> {
659        Self::try_from_str(rfc_9557_str, Iso)
660    }
661}
662
663impl<A: AsCalendar> DateTime<A> {
664    /// Creates a [`DateTime`] in any calendar from an RFC 9557 string.
665    ///
666    /// Returns an error if the string has a calendar annotation that does not
667    /// match the calendar argument, unless the argument is [`Iso`].
668    ///
669    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
670    ///
671    /// # Examples
672    ///
673    /// ```
674    /// use icu::calendar::cal::Hebrew;
675    /// use icu::time::DateTime;
676    ///
677    /// let datetime =
678    ///     DateTime::try_from_str("2024-07-17T16:01:17.045[u-ca=hebrew]", Hebrew)
679    ///         .unwrap();
680    ///
681    /// assert_eq!(datetime.date.era_year().year, 5784);
682    /// assert_eq!(
683    ///     datetime.date.month().standard_code,
684    ///     icu::calendar::types::MonthCode(tinystr::tinystr!(4, "M10"))
685    /// );
686    /// assert_eq!(datetime.date.day_of_month().0, 11);
687    ///
688    /// assert_eq!(datetime.time.hour.number(), 16);
689    /// assert_eq!(datetime.time.minute.number(), 1);
690    /// assert_eq!(datetime.time.second.number(), 17);
691    /// assert_eq!(datetime.time.subsecond.number(), 45000000);
692    /// ```
693    pub fn try_from_str(rfc_9557_str: &str, calendar: A) -> Result<Self, ParseError> {
694        Self::try_from_utf8(rfc_9557_str.as_bytes(), calendar)
695    }
696
697    /// Creates a [`DateTime`] in any calendar from an RFC 9557 string.
698    ///
699    /// See [`Self::try_from_str()`].
700    ///
701    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
702    pub fn try_from_utf8(rfc_9557_str: &[u8], calendar: A) -> Result<Self, ParseError> {
703        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
704        let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
705        let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
706        Ok(Self { date, time })
707    }
708}
709
710impl Time {
711    /// Creates a [`Time`] from an RFC 9557 string of a time.
712    ///
713    /// Does not support parsing an RFC 9557 string with a date and time; for that, use [`DateTime`].
714    ///
715    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
716    ///
717    /// # Examples
718    ///
719    /// ```
720    /// use icu::time::Time;
721    ///
722    /// let time = Time::try_from_str("16:01:17.045").unwrap();
723    ///
724    /// assert_eq!(time.hour.number(), 16);
725    /// assert_eq!(time.minute.number(), 1);
726    /// assert_eq!(time.second.number(), 17);
727    /// assert_eq!(time.subsecond.number(), 45000000);
728    /// ```
729    pub fn try_from_str(rfc_9557_str: &str) -> Result<Self, ParseError> {
730        Self::try_from_utf8(rfc_9557_str.as_bytes())
731    }
732
733    /// Creates a [`Time`] in the ISO-8601 calendar from an RFC 9557 string.
734    ///
735    /// ✨ *Enabled with the `ixdtf` Cargo feature.*
736    ///
737    /// See [`Self::try_from_str()`].
738    pub fn try_from_utf8(rfc_9557_str: &[u8]) -> Result<Self, ParseError> {
739        let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse_time()?;
740        Self::try_from_ixdtf_record(&ixdtf_record)
741    }
742
743    fn try_from_ixdtf_record(ixdtf_record: &IxdtfParseRecord) -> Result<Self, ParseError> {
744        let time_record = ixdtf_record.time.ok_or(ParseError::MissingFields)?;
745        Self::try_from_time_record(&time_record)
746    }
747
748    fn try_from_time_record(time_record: &TimeRecord) -> Result<Self, ParseError> {
749        let nanosecond = time_record
750            .fraction
751            .map(|fraction| {
752                fraction
753                    .to_nanoseconds()
754                    .ok_or(ParseError::ExcessivePrecision)
755            })
756            .transpose()?
757            .unwrap_or_default();
758
759        Ok(Self::try_new(
760            time_record.hour,
761            time_record.minute,
762            time_record.second,
763            nanosecond,
764        )?)
765    }
766}
767
768impl FromStr for Time {
769    type Err = ParseError;
770    fn from_str(rfc_9557_str: &str) -> Result<Self, Self::Err> {
771        Self::try_from_str(rfc_9557_str)
772    }
773}
774
775#[cfg(test)]
776mod test {
777    use super::*;
778    use crate::TimeZone;
779
780    #[test]
781    fn max_possible_rfc_9557_utc_offset() {
782        assert_eq!(
783            ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19+23:59:59.999999999", Iso)
784                .unwrap_err(),
785            ParseError::InvalidOffsetError
786        );
787    }
788
789    #[test]
790    fn zone_calculations() {
791        ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z", Iso).unwrap();
792        assert_eq!(
793            ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z[+08:00]", Iso)
794                .unwrap_err(),
795            ParseError::RequiresCalculation
796        );
797        assert_eq!(
798            ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z[Europe/Zurich]", Iso)
799                .unwrap_err(),
800            ParseError::MismatchedTimeZoneFields
801        );
802    }
803
804    #[test]
805    fn future_zone() {
806        let result = ZonedDateTime::try_lenient_from_str(
807            "2024-08-08T12:08:19[Future/Zone]",
808            Iso,
809            IanaParserBorrowed::new(),
810        )
811        .unwrap();
812        assert_eq!(result.zone.id(), TimeZone::UNKNOWN);
813        assert_eq!(result.zone.offset(), None);
814    }
815
816    #[test]
817    fn lax() {
818        ZonedDateTime::try_location_only_from_str(
819            "2024-10-18T15:44[America/Los_Angeles]",
820            icu_calendar::cal::Gregorian,
821            IanaParserBorrowed::new(),
822        )
823        .unwrap();
824    }
825}