1use std::fmt;
4use std::str::FromStr;
5
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::diagnostic::{Diagnostic, DiagnosticCategory};
10
11pub const MIN_CANONICAL_YEAR: i32 = -262_143;
13pub const MAX_CANONICAL_YEAR: i32 = 262_142;
15
16fn invalid_temporal(kind: &'static str) -> Diagnostic {
17 Diagnostic::stable(
18 DiagnosticCategory::InvalidContract,
19 "invalid_canonical_scalar",
20 "temporal value is outside its canonical grammar",
21 )
22 .with_detail("value_type", kind)
23}
24
25fn parse_digits(value: &str) -> Option<u32> {
26 (!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
27 .then(|| value.parse().ok())
28 .flatten()
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct CanonicalDate {
34 year: i32,
35 month: u8,
36 day: u8,
37}
38
39impl CanonicalDate {
40 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, Diagnostic> {
42 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
43 let max_day = match month {
44 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
45 4 | 6 | 9 | 11 => 30,
46 2 if leap => 29,
47 2 => 28,
48 _ => 0,
49 };
50 if !(MIN_CANONICAL_YEAR..=MAX_CANONICAL_YEAR).contains(&year) || day == 0 || day > max_day {
51 Err(invalid_temporal("date"))
52 } else {
53 Ok(Self { year, month, day })
54 }
55 }
56 pub const fn components(self) -> (i32, u8, u8) {
58 (self.year, self.month, self.day)
59 }
60}
61impl fmt::Display for CanonicalDate {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self.year {
64 0..=9999 => write!(f, "{:04}", self.year)?,
65 10_000.. => write!(f, "+{}", self.year)?,
66 -9999..=-1 => write!(f, "-{:04}", -self.year)?,
67 _ => write!(f, "{}", self.year)?,
68 }
69 write!(f, "-{:02}-{:02}", self.month, self.day)
70 }
71}
72impl FromStr for CanonicalDate {
73 type Err = Diagnostic;
74 fn from_str(value: &str) -> Result<Self, Self::Err> {
75 let (year_month, day) = value
76 .rsplit_once('-')
77 .ok_or_else(|| invalid_temporal("date"))?;
78 let (year, month) = year_month
79 .rsplit_once('-')
80 .ok_or_else(|| invalid_temporal("date"))?;
81 let parsed = Self::new(
82 year.parse::<i32>().map_err(|_| invalid_temporal("date"))?,
83 parse_digits(month).ok_or_else(|| invalid_temporal("date"))? as u8,
84 parse_digits(day).ok_or_else(|| invalid_temporal("date"))? as u8,
85 )?;
86 if parsed.to_string() != value {
87 Err(invalid_temporal("date"))
88 } else {
89 Ok(parsed)
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct CanonicalTime {
97 hour: u8,
98 minute: u8,
99 second: u8,
100 nanosecond: u32,
101}
102
103impl CanonicalTime {
104 pub fn new(hour: u8, minute: u8, second: u8, nanosecond: u32) -> Result<Self, Diagnostic> {
106 if hour > 23 || minute > 59 || second > 59 || nanosecond >= 1_000_000_000 {
107 Err(invalid_temporal("datetime"))
108 } else {
109 Ok(Self {
110 hour,
111 minute,
112 second,
113 nanosecond,
114 })
115 }
116 }
117 pub const fn components(self) -> (u8, u8, u8, u32) {
119 (self.hour, self.minute, self.second, self.nanosecond)
120 }
121}
122impl fmt::Display for CanonicalTime {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
125 if self.nanosecond != 0 {
126 let fraction = format!("{:09}", self.nanosecond);
127 write!(f, ".{}", fraction.trim_end_matches('0'))?;
128 }
129 Ok(())
130 }
131}
132impl FromStr for CanonicalTime {
133 type Err = Diagnostic;
134 fn from_str(value: &str) -> Result<Self, Self::Err> {
135 if !value.is_ascii() || value.len() < 8 || &value[2..3] != ":" || &value[5..6] != ":" {
136 return Err(invalid_temporal("datetime"));
137 }
138 let hour = parse_digits(&value[..2]).ok_or_else(|| invalid_temporal("datetime"))? as u8;
139 let minute = parse_digits(&value[3..5]).ok_or_else(|| invalid_temporal("datetime"))? as u8;
140 let (seconds, nanos) = match value[6..].split_once('.') {
141 Some((seconds, fraction))
142 if !fraction.is_empty()
143 && fraction.len() <= 9
144 && fraction.bytes().all(|b| b.is_ascii_digit()) =>
145 {
146 let mut padded = fraction.to_owned();
147 padded.extend(std::iter::repeat_n('0', 9 - fraction.len()));
148 (
149 seconds,
150 padded
151 .parse::<u32>()
152 .map_err(|_| invalid_temporal("datetime"))?,
153 )
154 }
155 Some(_) => return Err(invalid_temporal("datetime")),
156 None => (&value[6..], 0),
157 };
158 let parsed = Self::new(
159 hour,
160 minute,
161 u8::try_from(parse_digits(seconds).ok_or_else(|| invalid_temporal("datetime"))?)
162 .map_err(|_| invalid_temporal("datetime"))?,
163 nanos,
164 )?;
165 if parsed.to_string() != value {
166 Err(invalid_temporal("datetime"))
167 } else {
168 Ok(parsed)
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct CanonicalDateTime {
176 date: CanonicalDate,
177 time: CanonicalTime,
178}
179
180impl CanonicalDateTime {
181 pub const fn new(date: CanonicalDate, time: CanonicalTime) -> Self {
183 Self { date, time }
184 }
185 pub const fn date(self) -> CanonicalDate {
187 self.date
188 }
189 pub const fn time(self) -> CanonicalTime {
191 self.time
192 }
193}
194impl fmt::Display for CanonicalDateTime {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 write!(f, "{}T{}", self.date, self.time)
197 }
198}
199impl FromStr for CanonicalDateTime {
200 type Err = Diagnostic;
201 fn from_str(value: &str) -> Result<Self, Self::Err> {
202 let (date, time) = value
203 .split_once('T')
204 .ok_or_else(|| invalid_temporal("datetime"))?;
205 if time.contains('T') {
206 return Err(invalid_temporal("datetime"));
207 }
208 Ok(Self::new(date.parse()?, time.parse()?))
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
214pub enum TimeZoneDesignator {
215 Utc,
217 OffsetSeconds(i32),
219 Named(String),
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
225pub struct CanonicalDateTimeTz {
226 local: CanonicalDateTime,
227 zone: TimeZoneDesignator,
228 effective_offset_seconds: i32,
229}
230
231impl CanonicalDateTimeTz {
232 pub fn new(local: CanonicalDateTime, zone: TimeZoneDesignator) -> Result<Self, Diagnostic> {
234 Self::new_fixed(local, zone)
235 }
236
237 pub fn new_fixed(
239 local: CanonicalDateTime,
240 zone: TimeZoneDesignator,
241 ) -> Result<Self, Diagnostic> {
242 match &zone {
243 TimeZoneDesignator::Utc => Ok(Self {
244 local,
245 zone,
246 effective_offset_seconds: 0,
247 }),
248 TimeZoneDesignator::OffsetSeconds(seconds)
249 if seconds.unsigned_abs() <= 86_399 && *seconds != 0 =>
250 {
251 let effective_offset_seconds = *seconds;
252 Ok(Self {
253 local,
254 zone,
255 effective_offset_seconds,
256 })
257 }
258 TimeZoneDesignator::OffsetSeconds(_) | TimeZoneDesignator::Named(_) => {
259 Err(invalid_temporal("datetime_tz"))
260 }
261 }
262 }
263
264 pub fn new_named_resolved(
266 local: CanonicalDateTime,
267 name: impl Into<String>,
268 effective_offset_seconds: i32,
269 ) -> Result<Self, Diagnostic> {
270 let name = name.into();
271 if name.is_empty()
272 || name.len() > 255
273 || !name.bytes().all(|byte| {
274 byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'+')
275 })
276 || effective_offset_seconds.unsigned_abs() > 86_399
277 {
278 return Err(invalid_temporal("datetime_tz"));
279 }
280 Ok(Self {
281 local,
282 zone: TimeZoneDesignator::Named(name),
283 effective_offset_seconds,
284 })
285 }
286 pub const fn local(&self) -> CanonicalDateTime {
288 self.local
289 }
290 pub fn zone(&self) -> &TimeZoneDesignator {
292 &self.zone
293 }
294 pub const fn effective_offset_seconds(&self) -> i32 {
296 self.effective_offset_seconds
297 }
298
299 pub fn semantic_utc_nanoseconds(&self) -> i128 {
306 let (hour, minute, second, nanosecond) = self.local.time().components();
307 let local_nanoseconds = i128::from(date_order_key(self.local.date())) * 86_400_000_000_000
308 + i128::from(hour) * 3_600_000_000_000
309 + i128::from(minute) * 60_000_000_000
310 + i128::from(second) * 1_000_000_000
311 + i128::from(nanosecond);
312 local_nanoseconds - i128::from(self.effective_offset_seconds) * 1_000_000_000
313 }
314}
315
316fn date_order_key(date: CanonicalDate) -> i64 {
317 let (year, month, day) = date.components();
318 let adjusted_year = i64::from(year) - if month <= 2 { 1 } else { 0 };
319 let era = if adjusted_year >= 0 {
320 adjusted_year
321 } else {
322 adjusted_year - 399
323 } / 400;
324 let year_of_era = adjusted_year - era * 400;
325 let adjusted_month = i64::from(month) + if month > 2 { -3 } else { 9 };
326 let day_of_year = (153 * adjusted_month + 2) / 5 + i64::from(day) - 1;
327 era * 146_097 + year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year
328}
329
330#[derive(Serialize, Deserialize)]
331#[serde(tag = "kind", rename_all = "snake_case")]
332enum TimeZoneWire {
333 Utc,
334 OffsetSeconds { seconds: i32 },
335 Named { name: String },
336}
337
338#[derive(Serialize, Deserialize)]
339struct DateTimeTzWire {
340 local: CanonicalDateTime,
341 zone: TimeZoneWire,
342 effective_offset_seconds: i32,
343}
344
345impl Serialize for CanonicalDateTimeTz {
346 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
347 where
348 S: Serializer,
349 {
350 let zone = match &self.zone {
351 TimeZoneDesignator::Utc => TimeZoneWire::Utc,
352 TimeZoneDesignator::OffsetSeconds(seconds) => {
353 TimeZoneWire::OffsetSeconds { seconds: *seconds }
354 }
355 TimeZoneDesignator::Named(name) => TimeZoneWire::Named { name: name.clone() },
356 };
357 DateTimeTzWire {
358 local: self.local,
359 zone,
360 effective_offset_seconds: self.effective_offset_seconds,
361 }
362 .serialize(serializer)
363 }
364}
365
366impl<'de> Deserialize<'de> for CanonicalDateTimeTz {
367 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
368 where
369 D: Deserializer<'de>,
370 {
371 let wire = DateTimeTzWire::deserialize(deserializer)?;
372 match wire.zone {
373 TimeZoneWire::Utc if wire.effective_offset_seconds == 0 => {
374 Self::new_fixed(wire.local, TimeZoneDesignator::Utc).map_err(D::Error::custom)
375 }
376 TimeZoneWire::OffsetSeconds { seconds } if wire.effective_offset_seconds == seconds => {
377 Self::new_fixed(wire.local, TimeZoneDesignator::OffsetSeconds(seconds))
378 .map_err(D::Error::custom)
379 }
380 TimeZoneWire::Named { name } => {
381 Self::new_named_resolved(wire.local, name, wire.effective_offset_seconds)
382 .map_err(D::Error::custom)
383 }
384 TimeZoneWire::Utc | TimeZoneWire::OffsetSeconds { .. } => Err(D::Error::custom(
385 "timezone offset does not match its authored fixed zone",
386 )),
387 }
388 }
389}
390impl fmt::Display for CanonicalDateTimeTz {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 write!(f, "{}", self.local)?;
393 match &self.zone {
394 TimeZoneDesignator::Utc => f.write_str("Z"),
395 TimeZoneDesignator::Named(name) => write!(f, "[{name}]"),
396 TimeZoneDesignator::OffsetSeconds(seconds) => {
397 let sign = if *seconds < 0 { '-' } else { '+' };
398 let absolute = seconds.unsigned_abs();
399 write!(
400 f,
401 "{sign}{:02}:{:02}",
402 absolute / 3600,
403 (absolute % 3600) / 60
404 )?;
405 let seconds = absolute % 60;
406 if seconds != 0 {
407 write!(f, ":{seconds:02}")?;
408 }
409 Ok(())
410 }
411 }
412 }
413}
414impl FromStr for CanonicalDateTimeTz {
415 type Err = Diagnostic;
416 fn from_str(value: &str) -> Result<Self, Self::Err> {
417 if !value.is_ascii() {
418 return Err(invalid_temporal("datetime_tz"));
419 }
420 if let Some(local) = value.strip_suffix('Z') {
421 let parsed = Self::new_fixed(local.parse()?, TimeZoneDesignator::Utc)?;
422 return if parsed.to_string() == value {
423 Ok(parsed)
424 } else {
425 Err(invalid_temporal("datetime_tz"))
426 };
427 }
428 if value.ends_with(']') {
429 return Err(invalid_temporal("datetime_tz"));
430 }
431 let (split, seconds) = [9_usize, 6]
432 .into_iter()
433 .find_map(|width| {
434 let split = value.len().checked_sub(width)?;
435 let offset = &value[split..];
436 parse_fixed_offset_seconds(offset).map(|seconds| (split, seconds))
437 })
438 .ok_or_else(|| invalid_temporal("datetime_tz"))?;
439 let parsed = Self::new_fixed(
440 value[..split].parse()?,
441 TimeZoneDesignator::OffsetSeconds(seconds),
442 )?;
443 if parsed.to_string() != value {
444 Err(invalid_temporal("datetime_tz"))
445 } else {
446 Ok(parsed)
447 }
448 }
449}
450
451fn parse_fixed_offset_seconds(value: &str) -> Option<i32> {
452 let bytes = value.as_bytes();
453 if !matches!(
454 bytes,
455 [b'+' | b'-', _, _, b':', _, _] | [b'+' | b'-', _, _, b':', _, _, b':', _, _]
456 ) || !bytes
457 .iter()
458 .enumerate()
459 .filter(|(index, _)| !matches!(index, 0 | 3 | 6))
460 .all(|(_, byte)| byte.is_ascii_digit())
461 {
462 return None;
463 }
464 let component = |left: usize, right: usize| {
465 std::str::from_utf8(&bytes[left..right])
466 .ok()?
467 .parse::<i32>()
468 .ok()
469 };
470 let hours = component(1, 3)?;
471 let minutes = component(4, 6)?;
472 let seconds = if bytes.len() == 9 {
473 component(7, 9)?
474 } else {
475 0
476 };
477 if hours > 23 || minutes > 59 || seconds > 59 {
478 return None;
479 }
480 let magnitude = hours * 3_600 + minutes * 60 + seconds;
481 Some(if bytes[0] == b'-' {
482 -magnitude
483 } else {
484 magnitude
485 })
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
490pub struct CanonicalDuration {
491 negative: bool,
492 months: u64,
493 days: u64,
494 seconds: u64,
495 nanosecond: u32,
496}
497
498impl CanonicalDuration {
499 pub fn new(
501 negative: bool,
502 months: u64,
503 days: u64,
504 seconds: u64,
505 nanosecond: u32,
506 ) -> Result<Self, Diagnostic> {
507 if nanosecond >= 1_000_000_000 {
508 return Err(invalid_temporal("duration"));
509 }
510 let zero = months == 0 && days == 0 && seconds == 0 && nanosecond == 0;
511 Ok(Self {
512 negative: negative && !zero,
513 months,
514 days,
515 seconds,
516 nanosecond,
517 })
518 }
519 pub const fn components(self) -> (bool, u64, u64, u64, u32) {
521 (
522 self.negative,
523 self.months,
524 self.days,
525 self.seconds,
526 self.nanosecond,
527 )
528 }
529}
530impl fmt::Display for CanonicalDuration {
531 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532 if self.negative {
533 f.write_str("-")?;
534 }
535 f.write_str("P")?;
536 if self.months != 0 {
537 write!(f, "{}M", self.months)?;
538 }
539 if self.days != 0 {
540 write!(f, "{}D", self.days)?;
541 }
542 if self.seconds != 0 || self.nanosecond != 0 || (self.months == 0 && self.days == 0) {
543 write!(f, "T{}", self.seconds)?;
544 if self.nanosecond != 0 {
545 let fraction = format!("{:09}", self.nanosecond);
546 write!(f, ".{}", fraction.trim_end_matches('0'))?;
547 }
548 f.write_str("S")?;
549 }
550 Ok(())
551 }
552}
553
554fn canonical_unsigned(value: &str) -> Option<u64> {
555 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
556 return None;
557 }
558 let parsed = value.parse::<u64>().ok()?;
559 (parsed.to_string() == value).then_some(parsed)
560}
561
562impl FromStr for CanonicalDuration {
563 type Err = Diagnostic;
564 fn from_str(value: &str) -> Result<Self, Self::Err> {
565 let original = value;
566 let (negative, value) = value
567 .strip_prefix('-')
568 .map_or((false, value), |value| (true, value));
569 let body = value
570 .strip_prefix('P')
571 .ok_or_else(|| invalid_temporal("duration"))?;
572 if body == "T0S" {
573 let parsed = Self::new(negative, 0, 0, 0, 0)?;
574 return if parsed.to_string() == original {
575 Ok(parsed)
576 } else {
577 Err(invalid_temporal("duration"))
578 };
579 }
580 let (date, time) = body
581 .split_once('T')
582 .map_or((body, None), |(date, time)| (date, Some(time)));
583 let mut months = 0;
584 let mut days = 0;
585 let mut rest = date;
586 if let Some(index) = rest.find('M') {
587 months =
588 canonical_unsigned(&rest[..index]).ok_or_else(|| invalid_temporal("duration"))?;
589 rest = &rest[index + 1..];
590 }
591 if let Some(index) = rest.find('D') {
592 days =
593 canonical_unsigned(&rest[..index]).ok_or_else(|| invalid_temporal("duration"))?;
594 rest = &rest[index + 1..];
595 }
596 if !rest.is_empty() || months == 0 && date.contains('M') || days == 0 && date.contains('D')
597 {
598 return Err(invalid_temporal("duration"));
599 }
600 let (seconds, nanosecond) = if let Some(time) = time {
601 let seconds = time
602 .strip_suffix('S')
603 .ok_or_else(|| invalid_temporal("duration"))?;
604 match seconds.split_once('.') {
605 Some((whole, fraction))
606 if !fraction.is_empty()
607 && fraction.len() <= 9
608 && !fraction.ends_with('0')
609 && fraction.bytes().all(|b| b.is_ascii_digit()) =>
610 {
611 let mut padded = fraction.to_owned();
612 padded.extend(std::iter::repeat_n('0', 9 - fraction.len()));
613 (
614 canonical_unsigned(whole).ok_or_else(|| invalid_temporal("duration"))?,
615 padded.parse().map_err(|_| invalid_temporal("duration"))?,
616 )
617 }
618 Some(_) => return Err(invalid_temporal("duration")),
619 None => (
620 canonical_unsigned(seconds).ok_or_else(|| invalid_temporal("duration"))?,
621 0,
622 ),
623 }
624 } else {
625 (0, 0)
626 };
627 if months == 0 && days == 0 && seconds == 0 && nanosecond == 0 {
628 return Err(invalid_temporal("duration"));
629 }
630 let parsed = Self::new(negative, months, days, seconds, nanosecond)?;
631 if parsed.to_string() != original {
632 Err(invalid_temporal("duration"))
633 } else {
634 Ok(parsed)
635 }
636 }
637}
638
639macro_rules! temporal_serde {
640 ($type:ty) => {
641 impl Serialize for $type {
642 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
643 where
644 S: Serializer,
645 {
646 serializer.serialize_str(&self.to_string())
647 }
648 }
649 impl<'de> Deserialize<'de> for $type {
650 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
651 where
652 D: Deserializer<'de>,
653 {
654 String::deserialize(deserializer)?
655 .parse()
656 .map_err(D::Error::custom)
657 }
658 }
659 };
660}
661temporal_serde!(CanonicalDate);
662temporal_serde!(CanonicalDateTime);
663temporal_serde!(CanonicalDuration);
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn temporal_values_parse_and_reemit_canonically() {
671 assert_eq!(
672 "2024-02-29".parse::<CanonicalDate>().unwrap().to_string(),
673 "2024-02-29"
674 );
675 assert!("2023-02-29".parse::<CanonicalDate>().is_err());
676 assert_eq!(
677 "2024-01-02T03:04:05.12"
678 .parse::<CanonicalDateTime>()
679 .unwrap()
680 .to_string(),
681 "2024-01-02T03:04:05.12"
682 );
683 assert_eq!(
684 "2024-01-02T03:04:05Z"
685 .parse::<CanonicalDateTimeTz>()
686 .unwrap()
687 .to_string(),
688 "2024-01-02T03:04:05Z"
689 );
690 assert_eq!(
691 "P2M3DT4.5S"
692 .parse::<CanonicalDuration>()
693 .unwrap()
694 .to_string(),
695 "P2M3DT4.5S"
696 );
697 }
698
699 #[test]
700 fn temporal_parsers_reject_noncanonical_normalizing_spellings() {
701 for invalid in ["00:00:256", "03:04:05.1200", "03:04:005", "03:04:05.0"] {
702 assert!(invalid.parse::<CanonicalTime>().is_err(), "{invalid}");
703 }
704 for invalid in ["2024-01-02T03:04:05.1200", "2024-01-02T03:04:05+01:00:00"] {
705 assert!(invalid.parse::<CanonicalDateTimeTz>().is_err(), "{invalid}");
706 }
707 for invalid in ["-PT0S", "PT1.20S"] {
708 assert!(invalid.parse::<CanonicalDuration>().is_err(), "{invalid}");
709 }
710 }
711
712 #[test]
713 fn dates_cover_the_complete_provider_year_domain() {
714 for value in [
715 "-262143-01-01",
716 "-0001-12-31",
717 "0000-02-29",
718 "+262142-12-31",
719 ] {
720 assert_eq!(value.parse::<CanonicalDate>().unwrap().to_string(), value);
721 }
722 for value in ["-262144-01-01", "+262143-01-01", "+0001-01-01", "1-01-01"] {
723 assert!(
724 value.parse::<CanonicalDate>().is_err(),
725 "expected {value:?} to fail"
726 );
727 }
728 }
729
730 #[test]
731 fn timezone_wire_retains_authored_zone_and_resolved_instant() {
732 let local = "2024-10-27T01:30:00".parse::<CanonicalDateTime>().unwrap();
733 let value = CanonicalDateTimeTz::new_named_resolved(local, "Europe/London", 3600).unwrap();
734 let bytes = serde_json::to_string(&value).unwrap();
735 assert_eq!(
736 bytes,
737 r#"{"local":"2024-10-27T01:30:00","zone":{"kind":"named","name":"Europe/London"},"effective_offset_seconds":3600}"#,
738 );
739 assert_eq!(
740 serde_json::from_str::<CanonicalDateTimeTz>(&bytes).unwrap(),
741 value
742 );
743
744 let utc = CanonicalDateTimeTz::new_fixed(
745 "2024-01-01T12:00:00".parse().unwrap(),
746 TimeZoneDesignator::Utc,
747 )
748 .unwrap();
749 let offset = CanonicalDateTimeTz::new_fixed(
750 "2024-01-01T13:00:00".parse().unwrap(),
751 TimeZoneDesignator::OffsetSeconds(3600),
752 )
753 .unwrap();
754 assert_eq!(
755 utc.semantic_utc_nanoseconds(),
756 offset.semantic_utc_nanoseconds()
757 );
758
759 let later_overlap =
760 CanonicalDateTimeTz::new_named_resolved(local, "Europe/London", 0).unwrap();
761 assert!(value.semantic_utc_nanoseconds() < later_overlap.semantic_utc_nanoseconds());
762 }
763
764 #[test]
765 fn fixed_datetime_tz_round_trips_second_resolution_offsets() {
766 let value = CanonicalDateTimeTz::new_fixed(
767 "1900-01-01T12:00:00.25".parse().expect("local datetime"),
768 TimeZoneDesignator::OffsetSeconds(1_172),
769 )
770 .expect("historical fixed offset");
771 assert_eq!(value.to_string(), "1900-01-01T12:00:00.25+00:19:32");
772 assert_eq!(
773 value.to_string().parse::<CanonicalDateTimeTz>().unwrap(),
774 value
775 );
776
777 for invalid in [
778 "1900-01-01T12:00:00+00:19:60",
779 "1900-01-01T12:00:00+24:00:00",
780 "1900-01-01T12:00:00+00:19:3",
781 ] {
782 assert!(invalid.parse::<CanonicalDateTimeTz>().is_err(), "{invalid}");
783 }
784 }
785}