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}