Skip to main content

type_bridge_contract/
temporal.rs

1//! Canonical low-dependency temporal component values.
2
3use 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
11/// Minimum year representable by the canonical TypeDB date profile.
12pub const MIN_CANONICAL_YEAR: i32 = -262_143;
13/// Maximum year representable by the canonical TypeDB date profile.
14pub 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/// A Gregorian date in the canonical TypeDB year range.
32#[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    /// Validate calendar components.
41    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    /// Return `(year, month, day)`.
57    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/// A time-of-day without timezone or leap seconds.
95#[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    /// Validate time components.
105    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    /// Return `(hour, minute, second, nanosecond)`.
118    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/// A timezone-free date and time.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct CanonicalDateTime {
176    date: CanonicalDate,
177    time: CanonicalTime,
178}
179
180impl CanonicalDateTime {
181    /// Construct from validated components.
182    pub const fn new(date: CanonicalDate, time: CanonicalTime) -> Self {
183        Self { date, time }
184    }
185    /// Return the date component.
186    pub const fn date(self) -> CanonicalDate {
187        self.date
188    }
189    /// Return the time component.
190    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/// A canonical timezone designator without timezone-database resolution.
213#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
214pub enum TimeZoneDesignator {
215    /// Coordinated Universal Time.
216    Utc,
217    /// A fixed signed offset from UTC in seconds.
218    OffsetSeconds(i32),
219    /// A validated named timezone identifier.
220    Named(String),
221}
222
223/// A timezone-aware canonical date and time.
224#[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    /// Compatibility constructor for UTC and fixed-offset values.
233    pub fn new(local: CanonicalDateTime, zone: TimeZoneDesignator) -> Result<Self, Diagnostic> {
234        Self::new_fixed(local, zone)
235    }
236
237    /// Construct UTC or a fixed-offset value.
238    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    /// Construct a named-zone value with its provider-resolved effective offset.
265    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    /// Return the local date-time component.
287    pub const fn local(&self) -> CanonicalDateTime {
288        self.local
289    }
290    /// Return the written zone designator.
291    pub fn zone(&self) -> &TimeZoneDesignator {
292        &self.zone
293    }
294    /// Return the effective offset selected for this local value.
295    pub const fn effective_offset_seconds(&self) -> i32 {
296        self.effective_offset_seconds
297    }
298
299    /// Return the exact UTC instant as a timezone-independent nanosecond key.
300    ///
301    /// Provider-specific timezone resolution happens outside this low-dependency
302    /// crate. Once resolved, this key is the semantic identity used by range
303    /// comparison and schema fingerprints; the authored zone remains available
304    /// through [`Self::zone`].
305    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/// A normalized ISO-8601-subset duration.
489#[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    /// Construct a normalized duration from components.
500    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    /// Return `(negative, months, days, seconds, nanoseconds)`.
520    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}