1use crate::{
6 zone::{iana::IanaParserBorrowed, models, InvalidOffsetError, UtcOffset},
7 DateTime, Time, TimeZoneInfo, ZonedDateTime,
8};
9use core::str::FromStr;
10use icu_calendar::{AnyCalendarKind, AsCalendar, Date, DateError, Iso, RangeError};
11use ixdtf::{
12 encoding::Utf8,
13 parsers::IxdtfParser,
14 records::{
15 DateRecord, IxdtfParseRecord, TimeRecord, TimeZoneAnnotation, TimeZoneRecord,
16 UtcOffsetRecord, UtcOffsetRecordOrZ,
17 },
18 ParseError as Rfc9557ParseError,
19};
20
21#[derive(Debug, PartialEq, displaydoc::Display)]
23#[non_exhaustive]
24pub enum ParseError {
25 #[displaydoc("Syntax error in the RFC 9557 string: {0}")]
27 Syntax(Rfc9557ParseError),
28 #[displaydoc("Value out of range: {0}")]
30 Range(RangeError),
31 #[displaydoc("Parsed date and time records were not a valid ISO date: {0}")]
33 Date(DateError),
34 MissingFields,
36 InconsistentTimeUtcOffsets,
38 InvalidOffsetError,
40 ExcessivePrecision,
42 #[displaydoc("The set of time zone fields was not expected for the given type")]
46 MismatchedTimeZoneFields,
47 UnknownCalendar,
49 #[displaydoc("Expected calendar {0} but found calendar {1}")]
51 MismatchedCalendar(AnyCalendarKind, AnyCalendarKind),
52 #[displaydoc(
70 "A timezone calculation is required to interpret this string, which is not supported"
71 )]
72 RequiresCalculation,
73}
74
75impl core::error::Error for ParseError {}
76
77impl From<Rfc9557ParseError> for ParseError {
78 fn from(value: Rfc9557ParseError) -> Self {
79 Self::Syntax(value)
80 }
81}
82
83impl From<RangeError> for ParseError {
84 fn from(value: RangeError) -> Self {
85 Self::Range(value)
86 }
87}
88
89impl From<DateError> for ParseError {
90 fn from(value: DateError) -> Self {
91 Self::Date(value)
92 }
93}
94
95impl From<InvalidOffsetError> for ParseError {
96 fn from(_: InvalidOffsetError) -> Self {
97 Self::InvalidOffsetError
98 }
99}
100
101impl From<icu_calendar::ParseError> for ParseError {
102 fn from(value: icu_calendar::ParseError) -> Self {
103 match value {
104 icu_calendar::ParseError::MissingFields => Self::MissingFields,
105 icu_calendar::ParseError::Range(r) => Self::Range(r),
106 icu_calendar::ParseError::Syntax(s) => Self::Syntax(s),
107 icu_calendar::ParseError::UnknownCalendar => Self::UnknownCalendar,
108 _ => unreachable!(),
109 }
110 }
111}
112
113impl UtcOffset {
114 fn try_from_utc_offset_record(record: UtcOffsetRecord) -> Result<Self, ParseError> {
115 let hour_seconds = i32::from(record.hour()) * 3600;
116 let minute_seconds = i32::from(record.minute()) * 60;
117 Self::try_from_seconds(
118 i32::from(record.sign() as i8)
119 * (hour_seconds + minute_seconds + i32::from(record.second().unwrap_or(0))),
120 )
121 .map_err(Into::into)
122 }
123}
124
125struct Intermediate<'a> {
126 offset: Option<UtcOffsetRecord>,
127 is_z: bool,
128 iana_identifier: Option<&'a [u8]>,
129 date: DateRecord,
130 time: TimeRecord,
131}
132
133impl<'a> Intermediate<'a> {
134 fn try_from_ixdtf_record(
135 ixdtf_record: &'a IxdtfParseRecord<'_, Utf8>,
136 ) -> Result<Self, ParseError> {
137 let (offset, is_z, iana_identifier) = match ixdtf_record {
138 IxdtfParseRecord {
140 offset: None,
141 tz: None,
142 ..
143 } => (None, false, None),
144 IxdtfParseRecord {
146 offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
147 tz: None,
148 ..
149 } => (Some(*offset), false, None),
150 IxdtfParseRecord {
152 offset: Some(UtcOffsetRecordOrZ::Z),
153 tz: None,
154 ..
155 } => (None, true, None),
156 IxdtfParseRecord {
158 offset: None,
159 tz:
160 Some(TimeZoneAnnotation {
161 tz: TimeZoneRecord::Offset(offset),
162 ..
163 }),
164 ..
165 } => (Some(UtcOffsetRecord::MinutePrecision(*offset)), false, None),
166 IxdtfParseRecord {
168 offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
169 tz:
170 Some(TimeZoneAnnotation {
171 tz: TimeZoneRecord::Offset(offset1),
172 ..
173 }),
174 ..
175 } => {
176 let annotation_offset = UtcOffsetRecord::MinutePrecision(*offset1);
177 if offset != &annotation_offset {
178 return Err(ParseError::InconsistentTimeUtcOffsets);
179 }
180 (Some(*offset), false, None)
181 }
182 IxdtfParseRecord {
184 offset: Some(UtcOffsetRecordOrZ::Offset(offset)),
185 tz:
186 Some(TimeZoneAnnotation {
187 tz: TimeZoneRecord::Name(iana_identifier),
188 ..
189 }),
190 ..
191 } => (Some(*offset), false, Some(*iana_identifier)),
192 IxdtfParseRecord {
194 offset: Some(UtcOffsetRecordOrZ::Z),
195 tz:
196 Some(TimeZoneAnnotation {
197 tz: TimeZoneRecord::Offset(offset),
198 ..
199 }),
200 ..
201 } => (Some(UtcOffsetRecord::MinutePrecision(*offset)), true, None),
202 IxdtfParseRecord {
204 offset: Some(UtcOffsetRecordOrZ::Z),
205 tz:
206 Some(TimeZoneAnnotation {
207 tz: TimeZoneRecord::Name(iana_identifier),
208 ..
209 }),
210 ..
211 } => (None, true, Some(*iana_identifier)),
212 IxdtfParseRecord {
214 offset: None,
215 tz:
216 Some(TimeZoneAnnotation {
217 tz: TimeZoneRecord::Name(iana_identifier),
218 ..
219 }),
220 ..
221 } => (None, false, Some(*iana_identifier)),
222 IxdtfParseRecord {
224 tz: Some(TimeZoneAnnotation { tz, .. }),
225 ..
226 } => {
227 debug_assert!(false, "unexpected TimeZoneRecord: {tz:?}");
228 (None, false, None)
229 }
230 };
231 let IxdtfParseRecord {
232 date: Some(date),
233 time: Some(time),
234 ..
235 } = *ixdtf_record
236 else {
237 return Err(ParseError::MismatchedTimeZoneFields);
239 };
240 Ok(Self {
241 offset,
242 is_z,
243 iana_identifier,
244 date,
245 time,
246 })
247 }
248
249 fn offset_only(self) -> Result<UtcOffset, ParseError> {
250 let None = self.iana_identifier else {
251 return Err(ParseError::MismatchedTimeZoneFields);
252 };
253 if self.is_z {
254 if let Some(offset) = self.offset {
255 if offset != UtcOffsetRecord::zero() {
256 return Err(ParseError::RequiresCalculation);
257 }
258 }
259 return Ok(UtcOffset::zero());
260 }
261 let Some(offset) = self.offset else {
262 return Err(ParseError::MismatchedTimeZoneFields);
263 };
264 UtcOffset::try_from_utc_offset_record(offset)
265 }
266
267 fn location_only(
268 self,
269 iana_parser: IanaParserBorrowed<'_>,
270 ) -> Result<TimeZoneInfo<models::AtTime>, ParseError> {
271 let None = self.offset else {
272 return Err(ParseError::MismatchedTimeZoneFields);
273 };
274 let Some(iana_identifier) = self.iana_identifier else {
275 if self.is_z {
276 return Err(ParseError::RequiresCalculation);
277 }
278 return Err(ParseError::MismatchedTimeZoneFields);
279 };
280 let id = iana_parser.parse_from_utf8(iana_identifier);
281 let date = Date::<Iso>::try_new_iso(self.date.year, self.date.month, self.date.day)?;
282 let time = Time::try_from_time_record(&self.time)?;
283 Ok(id
284 .with_offset(None)
285 .at_date_time_iso(DateTime { date, time }))
286 }
287
288 fn lenient(
289 self,
290 iana_parser: IanaParserBorrowed<'_>,
291 ) -> Result<TimeZoneInfo<models::AtTime>, ParseError> {
292 let mut zone = match self.iana_identifier {
293 Some(iana_identifier) => {
294 if self.is_z {
295 return Err(ParseError::RequiresCalculation);
296 }
297 iana_parser
298 .parse_from_utf8(iana_identifier)
299 .with_offset(None)
300 }
301 None if self.is_z => TimeZoneInfo::utc(),
302 None => TimeZoneInfo::unknown(),
303 };
304
305 if let Some(offset) = self.offset {
306 let offset = UtcOffset::try_from_utc_offset_record(offset)?;
307 if zone.offset().is_some_and(|i| i != offset) {
308 return Err(ParseError::RequiresCalculation);
309 }
310 zone = zone.id().with_offset(Some(offset));
311 }
312 let date = Date::<Iso>::try_new_iso(self.date.year, self.date.month, self.date.day)?;
313 let time = Time::try_from_time_record(&self.time)?;
314 Ok(zone.at_date_time_iso(DateTime { date, time }))
315 }
316
317 #[allow(deprecated)]
318 fn all(
319 self,
320 iana_parser: IanaParserBorrowed<'_>,
321 ) -> Result<TimeZoneInfo<models::AtTime>, ParseError> {
322 let Some(offset) = self.offset else {
323 return Err(ParseError::MismatchedTimeZoneFields);
324 };
325 let Some(iana_identifier) = self.iana_identifier else {
326 return Err(ParseError::MismatchedTimeZoneFields);
327 };
328 let time_zone_id = iana_parser.parse_from_utf8(iana_identifier);
329 let date = Date::try_new_iso(self.date.year, self.date.month, self.date.day)?;
330 let time = Time::try_from_time_record(&self.time)?;
331 let offset = UtcOffset::try_from_utc_offset_record(offset)?;
332 Ok(time_zone_id
333 .with_offset(Some(offset))
334 .at_date_time_iso(DateTime { date, time }))
335 }
336
337 #[allow(deprecated)]
338 fn full(
339 self,
340 iana_parser: IanaParserBorrowed<'_>,
341 offset_calculator: crate::zone::VariantOffsetsCalculatorBorrowed,
342 ) -> Result<TimeZoneInfo<models::Full>, ParseError> {
343 let Some(offset) = self.offset else {
344 return Err(ParseError::MismatchedTimeZoneFields);
345 };
346 let Some(iana_identifier) = self.iana_identifier else {
347 return Err(ParseError::MismatchedTimeZoneFields);
348 };
349 let time_zone_id = iana_parser.parse_from_utf8(iana_identifier);
350 let date = Date::try_new_iso(self.date.year, self.date.month, self.date.day)?;
351 let time = Time::try_from_time_record(&self.time)?;
352 let offset = UtcOffset::try_from_utc_offset_record(offset)?;
353 Ok(time_zone_id
354 .with_offset(Some(offset))
355 .at_date_time_iso(DateTime { date, time })
356 .infer_variant(offset_calculator))
357 }
358}
359
360impl<A: AsCalendar> ZonedDateTime<A, UtcOffset> {
361 pub fn try_offset_only_from_str(rfc_9557_str: &str, calendar: A) -> Result<Self, ParseError> {
368 Self::try_offset_only_from_utf8(rfc_9557_str.as_bytes(), calendar)
369 }
370
371 pub fn try_offset_only_from_utf8(rfc_9557_str: &[u8], calendar: A) -> Result<Self, ParseError> {
375 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
376 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
377 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
378 let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?.offset_only()?;
379 Ok(ZonedDateTime { date, time, zone })
380 }
381}
382
383impl<A: AsCalendar> ZonedDateTime<A, TimeZoneInfo<models::AtTime>> {
384 pub fn try_location_only_from_str(
391 rfc_9557_str: &str,
392 calendar: A,
393 iana_parser: IanaParserBorrowed,
394 ) -> Result<Self, ParseError> {
395 Self::try_location_only_from_utf8(rfc_9557_str.as_bytes(), calendar, iana_parser)
396 }
397
398 pub fn try_location_only_from_utf8(
402 rfc_9557_str: &[u8],
403 calendar: A,
404 iana_parser: IanaParserBorrowed,
405 ) -> Result<Self, ParseError> {
406 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
407 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
408 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
409 let zone =
410 Intermediate::try_from_ixdtf_record(&ixdtf_record)?.location_only(iana_parser)?;
411 Ok(ZonedDateTime { date, time, zone })
412 }
413
414 pub fn try_lenient_from_str(
422 rfc_9557_str: &str,
423 calendar: A,
424 iana_parser: IanaParserBorrowed,
425 ) -> Result<Self, ParseError> {
426 Self::try_lenient_from_utf8(rfc_9557_str.as_bytes(), calendar, iana_parser)
427 }
428
429 pub fn try_lenient_from_utf8(
433 rfc_9557_str: &[u8],
434 calendar: A,
435 iana_parser: IanaParserBorrowed,
436 ) -> Result<Self, ParseError> {
437 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
438 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
439 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
440 let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?.lenient(iana_parser)?;
441 Ok(ZonedDateTime { date, time, zone })
442 }
443
444 pub fn try_strict_from_str(
591 rfc_9557_str: &str,
592 calendar: A,
593 iana_parser: IanaParserBorrowed,
594 ) -> Result<Self, ParseError> {
595 Self::try_strict_from_utf8(rfc_9557_str.as_bytes(), calendar, iana_parser)
596 }
597
598 pub fn try_strict_from_utf8(
602 rfc_9557_str: &[u8],
603 calendar: A,
604 iana_parser: IanaParserBorrowed,
605 ) -> Result<Self, ParseError> {
606 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
607 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
608 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
609 let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?.all(iana_parser)?;
610
611 Ok(ZonedDateTime { date, time, zone })
612 }
613}
614
615#[allow(deprecated)]
616impl<A: AsCalendar> ZonedDateTime<A, TimeZoneInfo<models::Full>> {
617 #[deprecated(since = "2.1.0", note = "use `try_strict_from_str`")]
619 pub fn try_full_from_str(
620 rfc_9557_str: &str,
621 calendar: A,
622 iana_parser: IanaParserBorrowed,
623 offset_calculator: crate::zone::VariantOffsetsCalculatorBorrowed,
624 ) -> Result<Self, ParseError> {
625 Self::try_full_from_utf8(
626 rfc_9557_str.as_bytes(),
627 calendar,
628 iana_parser,
629 offset_calculator,
630 )
631 }
632
633 #[deprecated(since = "2.1.0", note = "use `try_strict_from_utf8`")]
637 pub fn try_full_from_utf8(
638 rfc_9557_str: &[u8],
639 calendar: A,
640 iana_parser: IanaParserBorrowed,
641 offset_calculator: crate::zone::VariantOffsetsCalculatorBorrowed,
642 ) -> Result<Self, ParseError> {
643 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
644 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
645 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
646 let zone = Intermediate::try_from_ixdtf_record(&ixdtf_record)?
647 .full(iana_parser, offset_calculator)?;
648
649 Ok(ZonedDateTime { date, time, zone })
650 }
651}
652
653impl FromStr for DateTime<Iso> {
654 type Err = ParseError;
655 fn from_str(rfc_9557_str: &str) -> Result<Self, Self::Err> {
656 Self::try_from_str(rfc_9557_str, Iso)
657 }
658}
659
660impl<A: AsCalendar> DateTime<A> {
661 pub fn try_from_str(rfc_9557_str: &str, calendar: A) -> Result<Self, ParseError> {
691 Self::try_from_utf8(rfc_9557_str.as_bytes(), calendar)
692 }
693
694 pub fn try_from_utf8(rfc_9557_str: &[u8], calendar: A) -> Result<Self, ParseError> {
700 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse()?;
701 let date = Date::try_from_ixdtf_record(&ixdtf_record, calendar)?;
702 let time = Time::try_from_ixdtf_record(&ixdtf_record)?;
703 Ok(Self { date, time })
704 }
705}
706
707impl Time {
708 pub fn try_from_str(rfc_9557_str: &str) -> Result<Self, ParseError> {
727 Self::try_from_utf8(rfc_9557_str.as_bytes())
728 }
729
730 pub fn try_from_utf8(rfc_9557_str: &[u8]) -> Result<Self, ParseError> {
736 let ixdtf_record = IxdtfParser::from_utf8(rfc_9557_str).parse_time()?;
737 Self::try_from_ixdtf_record(&ixdtf_record)
738 }
739
740 fn try_from_ixdtf_record(
741 ixdtf_record: &IxdtfParseRecord<'_, Utf8>,
742 ) -> Result<Self, ParseError> {
743 let time_record = ixdtf_record.time.ok_or(ParseError::MissingFields)?;
744 Self::try_from_time_record(&time_record)
745 }
746
747 fn try_from_time_record(time_record: &TimeRecord) -> Result<Self, ParseError> {
748 let nanosecond = time_record
749 .fraction
750 .map(|fraction| {
751 fraction
752 .to_nanoseconds()
753 .ok_or(ParseError::ExcessivePrecision)
754 })
755 .transpose()?
756 .unwrap_or_default();
757
758 Ok(Self::try_new(
759 time_record.hour,
760 time_record.minute,
761 time_record.second,
762 nanosecond,
763 )?)
764 }
765}
766
767impl FromStr for Time {
768 type Err = ParseError;
769 fn from_str(rfc_9557_str: &str) -> Result<Self, Self::Err> {
770 Self::try_from_str(rfc_9557_str)
771 }
772}
773
774#[cfg(test)]
775mod test {
776 use super::*;
777 use crate::TimeZone;
778
779 #[test]
780 fn max_possible_rfc_9557_utc_offset() {
781 assert_eq!(
782 ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19+23:59:59.999999999", Iso)
783 .unwrap_err(),
784 ParseError::InvalidOffsetError
785 );
786 }
787
788 #[test]
789 fn zone_calculations() {
790 ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z", Iso).unwrap();
791 assert_eq!(
792 ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z[+08:00]", Iso)
793 .unwrap_err(),
794 ParseError::RequiresCalculation
795 );
796 assert_eq!(
797 ZonedDateTime::try_offset_only_from_str("2024-08-08T12:08:19Z[Europe/Zurich]", Iso)
798 .unwrap_err(),
799 ParseError::MismatchedTimeZoneFields
800 );
801 }
802
803 #[test]
804 fn future_zone() {
805 let result = ZonedDateTime::try_location_only_from_str(
806 "2024-08-08T12:08:19[Future/Zone]",
807 Iso,
808 IanaParserBorrowed::new(),
809 )
810 .unwrap();
811 assert_eq!(result.zone.id(), TimeZone::UNKNOWN);
812 assert_eq!(result.zone.offset(), None);
813 }
814
815 #[test]
816 fn lax() {
817 ZonedDateTime::try_location_only_from_str(
818 "2024-10-18T15:44[America/Los_Angeles]",
819 icu_calendar::cal::Gregorian,
820 IanaParserBorrowed::new(),
821 )
822 .unwrap();
823 }
824}