Skip to main content

sup_xml_core/xsd/
datetime.rs

1//! XSD date/time/duration parsers.
2//!
3//! XSD §3.2.6–3.2.13 — eight calendar types plus `xs:duration`.  All use
4//! an ISO-8601 *subset*; XSD diverges from full ISO-8601 in a few ways
5//! (no week dates, year zero forbidden, leap-second support).
6//!
7//! Timezone:
8//! * Optional on every type.
9//! * `Z` means UTC (offset 0).
10//! * `+HH:MM` / `-HH:MM` ranges from `-14:00` to `+14:00`.
11//!
12//! Comparison of two values without timezones is *partial* — they're
13//! treated as distinct points in the implicit local timezone and compare
14//! equal only on exact field match.  Comparisons with at least one
15//! timezone-bearing operand normalise to UTC.
16
17use std::cmp::Ordering;
18
19use super::types::{TypeError, Value};
20
21// ── value-space structs ──────────────────────────────────────────────────────
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct XsdDateTime {
25    /// Year — can be negative, can exceed 4 digits.  Year zero is illegal
26    /// per XSD §3.2.7.
27    pub year:   i32,
28    pub month:  u8,
29    pub day:    u8,
30    pub hour:   u8,
31    pub minute: u8,
32    pub second: u8,
33    /// Fractional seconds in nanoseconds (0..1_000_000_000).
34    pub nanos:  u32,
35    /// Offset from UTC in minutes (`-840..=840`).  `None` = no timezone.
36    pub tz_min: Option<i16>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct XsdDate {
41    pub year:   i32,
42    pub month:  u8,
43    pub day:    u8,
44    pub tz_min: Option<i16>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub struct XsdTime {
49    pub hour:   u8,
50    pub minute: u8,
51    pub second: u8,
52    pub nanos:  u32,
53    pub tz_min: Option<i16>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub struct XsdGYearMonth { pub year: i32, pub month: u8, pub tz_min: Option<i16> }
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub struct XsdGYear { pub year: i32, pub tz_min: Option<i16> }
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub struct XsdGMonthDay { pub month: u8, pub day: u8, pub tz_min: Option<i16> }
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct XsdGDay { pub day: u8, pub tz_min: Option<i16> }
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub struct XsdGMonth { pub month: u8, pub tz_min: Option<i16> }
70
71/// `xs:duration` — split into a months part (years×12 + months) and a
72/// seconds part (days×86400 + h×3600 + m×60 + s).  This split is forced
73/// by the spec: months cannot be reduced to seconds without a reference
74/// date.  Two durations compare equal only when both parts match.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub struct XsdDuration {
77    pub months:  i64,
78    /// Whole seconds — combined with `nanos` for sub-second precision.
79    pub seconds: i64,
80    pub nanos:   u32,
81}
82
83// ── tiny lexer ───────────────────────────────────────────────────────────────
84
85struct Cur<'a> { s: &'a [u8], i: usize }
86
87impl<'a> Cur<'a> {
88    fn new(s: &'a str) -> Self { Self { s: s.as_bytes(), i: 0 } }
89    fn done(&self) -> bool     { self.i >= self.s.len() }
90    fn peek(&self) -> Option<u8> { self.s.get(self.i).copied() }
91    fn bump(&mut self) -> Option<u8> {
92        let b = self.peek()?; self.i += 1; Some(b)
93    }
94    fn expect(&mut self, b: u8) -> Result<(), TypeError> {
95        if self.bump() == Some(b) {
96            Ok(())
97        } else {
98            Err(TypeError::type_mismatch(format!("expected {:?}", b as char)))
99        }
100    }
101    /// Read at least `min` decimal digits, return their value as i64.
102    fn read_digits(&mut self, min: usize, max: usize) -> Result<i64, TypeError> {
103        let start = self.i;
104        while self.i < self.s.len()
105            && self.s[self.i].is_ascii_digit()
106            && self.i - start < max
107        {
108            self.i += 1;
109        }
110        let n = self.i - start;
111        if n < min {
112            return Err(TypeError::type_mismatch(
113                format!("expected at least {min} digits, got {n}")
114            ));
115        }
116        std::str::from_utf8(&self.s[start..self.i])
117            .unwrap()
118            .parse::<i64>()
119            .map_err(|e| TypeError::type_mismatch(format!("digit parse: {e}")))
120    }
121    fn at_end(&self) -> bool { self.done() }
122}
123
124// ── component parsers (shared building blocks) ───────────────────────────────
125
126fn read_year(c: &mut Cur) -> Result<i32, TypeError> {
127    let neg = c.peek() == Some(b'-');
128    if neg { c.bump(); }
129    // At least 4 digits, can be more (year 12345 is legal).
130    let start = c.i;
131    while c.i < c.s.len() && c.s[c.i].is_ascii_digit() {
132        c.i += 1;
133    }
134    let n = c.i - start;
135    if n < 4 {
136        return Err(TypeError::type_mismatch(
137            format!("year requires at least 4 digits, got {n}")
138        ));
139    }
140    // Reject leading zero on a year longer than 4 digits.
141    if n > 4 && c.s[start] == b'0' {
142        return Err(TypeError::type_mismatch(
143            "extended year cannot start with 0"
144        ));
145    }
146    let body: i64 = std::str::from_utf8(&c.s[start..c.i]).unwrap().parse().unwrap();
147    if body == 0 {
148        return Err(TypeError::type_mismatch("year zero is not allowed in XSD"));
149    }
150    let signed = if neg { -body } else { body };
151    if signed < i32::MIN as i64 || signed > i32::MAX as i64 {
152        return Err(TypeError::type_mismatch("year out of i32 range"));
153    }
154    Ok(signed as i32)
155}
156
157fn read_month(c: &mut Cur) -> Result<u8, TypeError> {
158    let n = c.read_digits(2, 2)?;
159    if !(1..=12).contains(&n) {
160        return Err(TypeError::type_mismatch(format!("month {n} out of range")));
161    }
162    Ok(n as u8)
163}
164
165fn read_day(c: &mut Cur) -> Result<u8, TypeError> {
166    let n = c.read_digits(2, 2)?;
167    if !(1..=31).contains(&n) {
168        return Err(TypeError::type_mismatch(format!("day {n} out of range")));
169    }
170    Ok(n as u8)
171}
172
173fn read_hh(c: &mut Cur) -> Result<u8, TypeError> {
174    let n = c.read_digits(2, 2)?;
175    if !(0..=24).contains(&n) {
176        return Err(TypeError::type_mismatch(format!("hour {n} out of range")));
177    }
178    Ok(n as u8)
179}
180
181fn read_mm(c: &mut Cur) -> Result<u8, TypeError> {
182    let n = c.read_digits(2, 2)?;
183    if !(0..=59).contains(&n) {
184        return Err(TypeError::type_mismatch(format!("minute {n} out of range")));
185    }
186    Ok(n as u8)
187}
188
189fn read_ss_with_nanos(c: &mut Cur) -> Result<(u8, u32), TypeError> {
190    let s = c.read_digits(2, 2)?;
191    if !(0..=60).contains(&s) {
192        return Err(TypeError::type_mismatch(format!("second {s} out of range")));
193    }
194    let nanos = if c.peek() == Some(b'.') {
195        c.bump();
196        let start = c.i;
197        while c.i < c.s.len() && c.s[c.i].is_ascii_digit() {
198            c.i += 1;
199        }
200        let n = c.i - start;
201        if n == 0 {
202            return Err(TypeError::type_mismatch("expected fractional digits after '.'"));
203        }
204        // Convert up to 9 digits to nanoseconds; truncate beyond.
205        let take = n.min(9);
206        let bytes = &c.s[start..start + take];
207        let val: u32 = std::str::from_utf8(bytes).unwrap().parse().unwrap();
208        // Pad/scale to nanoseconds.
209        let scale = 10u32.pow(9 - take as u32);
210        val * scale
211    } else { 0 };
212    Ok((s as u8, nanos))
213}
214
215/// Parse the optional timezone suffix.  Returns `None` if EOF, the
216/// offset in minutes otherwise.  Errors on partial/invalid suffix.
217fn read_tz(c: &mut Cur) -> Result<Option<i16>, TypeError> {
218    if c.done() { return Ok(None); }
219    match c.bump().unwrap() {
220        b'Z' => Ok(Some(0)),
221        sign @ (b'+' | b'-') => {
222            let h = c.read_digits(2, 2)?;
223            c.expect(b':')?;
224            let m = c.read_digits(2, 2)?;
225            if h > 14 || m > 59 || (h == 14 && m != 0) {
226                return Err(TypeError::type_mismatch(
227                    format!("timezone {sign:?}{h:02}:{m:02} out of range")
228                ));
229            }
230            let total = (h as i16) * 60 + (m as i16);
231            Ok(Some(if sign == b'-' { -total } else { total }))
232        }
233        other => Err(TypeError::type_mismatch(
234            format!("expected timezone (Z or +/-HH:MM), got {:?}", other as char)
235        )),
236    }
237}
238
239fn finish(c: &mut Cur) -> Result<(), TypeError> {
240    if c.at_end() { Ok(()) }
241    else { Err(TypeError::type_mismatch(format!(
242        "trailing junk at offset {}", c.i
243    ))) }
244}
245
246fn validate_day_in_month(year: i32, month: u8, day: u8) -> Result<(), TypeError> {
247    let max = days_in_month(year, month);
248    if day > max {
249        return Err(TypeError::type_mismatch(
250            format!("day {day} out of range for {year}-{month:02}")
251        ));
252    }
253    Ok(())
254}
255
256fn days_in_month(year: i32, month: u8) -> u8 {
257    match month {
258        1|3|5|7|8|10|12 => 31,
259        4|6|9|11 => 30,
260        2 => if is_leap_year(year) { 29 } else { 28 },
261        _ => 0,
262    }
263}
264
265fn is_leap_year(y: i32) -> bool {
266    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
267}
268
269// ── public per-type parsers ──────────────────────────────────────────────────
270
271pub fn parse_date_time(s: &str) -> Result<Value, TypeError> {
272    let mut c = Cur::new(s);
273    let year   = read_year(&mut c)?;
274    c.expect(b'-')?;
275    let month  = read_month(&mut c)?;
276    c.expect(b'-')?;
277    let day    = read_day(&mut c)?;
278    validate_day_in_month(year, month, day)?;
279    c.expect(b'T')?;
280    let hour   = read_hh(&mut c)?;
281    c.expect(b':')?;
282    let minute = read_mm(&mut c)?;
283    c.expect(b':')?;
284    let (second, nanos) = read_ss_with_nanos(&mut c)?;
285    if hour == 24 && (minute != 0 || second != 0 || nanos != 0) {
286        return Err(TypeError::type_mismatch("24:xx:xx invalid (only 24:00:00 allowed)"));
287    }
288    let tz_min = read_tz(&mut c)?;
289    finish(&mut c)?;
290    Ok(Value::DateTime(XsdDateTime { year, month, day, hour, minute, second, nanos, tz_min }))
291}
292
293pub fn parse_date(s: &str) -> Result<Value, TypeError> {
294    let mut c = Cur::new(s);
295    let year  = read_year(&mut c)?;
296    c.expect(b'-')?;
297    let month = read_month(&mut c)?;
298    c.expect(b'-')?;
299    let day   = read_day(&mut c)?;
300    validate_day_in_month(year, month, day)?;
301    let tz_min = read_tz(&mut c)?;
302    finish(&mut c)?;
303    Ok(Value::Date(XsdDate { year, month, day, tz_min }))
304}
305
306pub fn parse_time(s: &str) -> Result<Value, TypeError> {
307    let mut c = Cur::new(s);
308    let hour   = read_hh(&mut c)?;
309    c.expect(b':')?;
310    let minute = read_mm(&mut c)?;
311    c.expect(b':')?;
312    let (second, nanos) = read_ss_with_nanos(&mut c)?;
313    if hour == 24 && (minute != 0 || second != 0 || nanos != 0) {
314        return Err(TypeError::type_mismatch("24:xx:xx invalid"));
315    }
316    let tz_min = read_tz(&mut c)?;
317    finish(&mut c)?;
318    Ok(Value::Time(XsdTime { hour, minute, second, nanos, tz_min }))
319}
320
321pub fn parse_g_year_month(s: &str) -> Result<Value, TypeError> {
322    let mut c = Cur::new(s);
323    let year  = read_year(&mut c)?;
324    c.expect(b'-')?;
325    let month = read_month(&mut c)?;
326    let tz_min = read_tz(&mut c)?;
327    finish(&mut c)?;
328    Ok(Value::GYearMonth(XsdGYearMonth { year, month, tz_min }))
329}
330
331pub fn parse_g_year(s: &str) -> Result<Value, TypeError> {
332    let mut c = Cur::new(s);
333    let year  = read_year(&mut c)?;
334    let tz_min = read_tz(&mut c)?;
335    finish(&mut c)?;
336    Ok(Value::GYear(XsdGYear { year, tz_min }))
337}
338
339pub fn parse_g_month_day(s: &str) -> Result<Value, TypeError> {
340    let mut c = Cur::new(s);
341    c.expect(b'-')?; c.expect(b'-')?;
342    let month = read_month(&mut c)?;
343    c.expect(b'-')?;
344    let day   = read_day(&mut c)?;
345    if day > days_in_month(2000 /* ignore-leap */, month) && !(month == 2 && day == 29) {
346        return Err(TypeError::type_mismatch(
347            format!("day {day} out of range for month {month:02}")
348        ));
349    }
350    let tz_min = read_tz(&mut c)?;
351    finish(&mut c)?;
352    Ok(Value::GMonthDay(XsdGMonthDay { month, day, tz_min }))
353}
354
355pub fn parse_g_day(s: &str) -> Result<Value, TypeError> {
356    let mut c = Cur::new(s);
357    c.expect(b'-')?; c.expect(b'-')?; c.expect(b'-')?;
358    let day = read_day(&mut c)?;
359    let tz_min = read_tz(&mut c)?;
360    finish(&mut c)?;
361    Ok(Value::GDay(XsdGDay { day, tz_min }))
362}
363
364pub fn parse_g_month(s: &str) -> Result<Value, TypeError> {
365    let mut c = Cur::new(s);
366    c.expect(b'-')?; c.expect(b'-')?;
367    let month = read_month(&mut c)?;
368    // XSD 1.0 originally specified `--MM--`; XML Schema Errata
369    // corrected the form to `--MM` while leaving the legacy form
370    // in the wild. Accept the trailing `--` when it appears with
371    // no time-zone designator in between.
372    if c.peek() == Some(b'-') && c.s.get(c.i + 1).copied() == Some(b'-') {
373        c.bump(); c.bump();
374    }
375    let tz_min = read_tz(&mut c)?;
376    finish(&mut c)?;
377    Ok(Value::GMonth(XsdGMonth { month, tz_min }))
378}
379
380// ── duration ─────────────────────────────────────────────────────────────────
381
382pub fn parse_duration(s: &str) -> Result<Value, TypeError> {
383    let mut c = Cur::new(s);
384    let neg = c.peek() == Some(b'-');
385    if neg { c.bump(); }
386    c.expect(b'P')?;
387
388    let mut years   = 0i64;
389    let mut months  = 0i64;
390    let mut days    = 0i64;
391    let mut hours   = 0i64;
392    let mut mins    = 0i64;
393    let mut secs    = 0i64;
394    let mut nanos   = 0u32;
395
396    let mut saw_any_date = false;
397    let mut saw_any_time = false;
398
399    // Date part.
400    while let Some(b) = c.peek() {
401        if b == b'T' { break; }
402        let n = c.read_digits(1, 18)?;
403        match c.bump() {
404            Some(b'Y') => { years   = n; saw_any_date = true; }
405            Some(b'M') => { months  = n; saw_any_date = true; }
406            Some(b'D') => { days    = n; saw_any_date = true; }
407            Some(b'T') | None => return Err(TypeError::type_mismatch(
408                "duration date part missing designator (Y/M/D)"
409            )),
410            Some(other) => return Err(TypeError::type_mismatch(
411                format!("unexpected {:?} in duration date part", other as char)
412            )),
413        }
414    }
415
416    // Time part.
417    if c.peek() == Some(b'T') {
418        c.bump();
419        if c.done() {
420            return Err(TypeError::type_mismatch(
421                "duration T must be followed by at least one time component"
422            ));
423        }
424        while !c.done() {
425            // Allow fractional seconds: read digits, then optionally `.frac`,
426            // then designator.
427            let n = c.read_digits(1, 18)?;
428            // Look for a possible fractional part — only valid for seconds.
429            let frac_nanos = if c.peek() == Some(b'.') {
430                c.bump();
431                let start = c.i;
432                while c.i < c.s.len() && c.s[c.i].is_ascii_digit() { c.i += 1; }
433                let take = (c.i - start).min(9);
434                // XSD §3.2.6: the decimal point in a duration must be
435                // followed by at least one digit.  `PT1.S` is a
436                // malformed value — return a TypeError instead of
437                // panicking inside `parse::<u32>()` on the empty
438                // slice.
439                if take == 0 {
440                    return Err(TypeError::type_mismatch(
441                        "duration: '.' must be followed by at least one fraction digit",
442                    ));
443                }
444                let bytes = &c.s[start..start + take];
445                // SAFETY of the two unwraps: `bytes` is a slice of
446                // characters we just confirmed are ASCII digits (so
447                // valid UTF-8), and at most 9 digits parses to at
448                // most 999_999_999 which fits in u32.
449                let val: u32 = std::str::from_utf8(bytes).unwrap().parse().unwrap();
450                let scale = 10u32.pow(9 - take as u32);
451                val * scale
452            } else { 0 };
453            match c.bump() {
454                Some(b'H') => { hours = n; saw_any_time = true; }
455                Some(b'M') => { mins  = n; saw_any_time = true; }
456                Some(b'S') => { secs  = n; nanos = frac_nanos; saw_any_time = true; }
457                Some(other) => return Err(TypeError::type_mismatch(
458                    format!("unexpected {:?} in duration time part", other as char)
459                )),
460                None => return Err(TypeError::type_mismatch(
461                    "duration time component missing designator"
462                )),
463            }
464        }
465    }
466
467    if !saw_any_date && !saw_any_time {
468        return Err(TypeError::type_mismatch("empty duration"));
469    }
470
471    let total_months  = years.saturating_mul(12).saturating_add(months);
472    let total_seconds = days.saturating_mul(86400)
473        .saturating_add(hours.saturating_mul(3600))
474        .saturating_add(mins.saturating_mul(60))
475        .saturating_add(secs);
476
477    let (m, s, ns) = if neg {
478        (-total_months, -total_seconds, nanos)
479    } else {
480        (total_months, total_seconds, nanos)
481    };
482
483    Ok(Value::Duration(XsdDuration { months: m, seconds: s, nanos: ns }))
484}
485
486// ── ordering helpers (used by order-facet checks for date/time types) ────────
487
488impl XsdDateTime {
489    /// Convert to a UTC instant in (year, day-of-year-style) units.
490    /// Returns the number of seconds since 0001-01-01T00:00:00 UTC, after
491    /// applying the timezone offset.  Fractional seconds in nanos.
492    pub fn to_utc_seconds(&self) -> Option<(i128, u32)> {
493        let tz = self.tz_min.unwrap_or(0);
494        let base = days_from_civil(self.year, self.month, self.day);
495        let mut seconds = (base as i128) * 86400
496            + (self.hour as i128) * 3600
497            + (self.minute as i128) * 60
498            + self.second as i128;
499        seconds -= (tz as i128) * 60;
500        Some((seconds, self.nanos))
501    }
502}
503
504impl Ord for XsdDateTime {
505    fn cmp(&self, other: &Self) -> Ordering {
506        match (self.to_utc_seconds(), other.to_utc_seconds()) {
507            (Some(a), Some(b)) => a.cmp(&b),
508            _ => Ordering::Equal,
509        }
510    }
511}
512impl PartialOrd for XsdDateTime { fn partial_cmp(&self, o: &Self) -> Option<Ordering> { Some(self.cmp(o)) } }
513
514/// Days from 0000-03-01 (proleptic Gregorian) to (y, m, d).  Standard
515/// "civil from days" Hinnant algorithm.
516fn days_from_civil(y: i32, m: u8, d: u8) -> i64 {
517    let y = y as i64 - if m <= 2 { 1 } else { 0 };
518    let era = if y >= 0 { y } else { y - 399 } / 400;
519    let yoe = (y - era * 400) as i64; // 0..399
520    let m = m as i64;
521    let d = d as i64;
522    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
523    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
524    era * 146097 + doe - 719468
525}
526
527// ── tests ────────────────────────────────────────────────────────────────────
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn ok<T>(r: Result<T, TypeError>) -> T {
534        r.unwrap_or_else(|e| panic!("{e:?}"))
535    }
536
537    // ── dateTime ─────────────────────────────────────────────────────
538
539    #[test]
540    fn datetime_basic_no_tz() {
541        let v = ok(parse_date_time("2024-03-15T10:30:00"));
542        match v {
543            Value::DateTime(d) => {
544                assert_eq!((d.year, d.month, d.day), (2024, 3, 15));
545                assert_eq!((d.hour, d.minute, d.second), (10, 30, 0));
546                assert_eq!(d.tz_min, None);
547            }
548            _ => panic!(),
549        }
550    }
551
552    #[test]
553    fn datetime_with_z() {
554        let v = ok(parse_date_time("2024-03-15T10:30:00Z"));
555        match v {
556            Value::DateTime(d) => assert_eq!(d.tz_min, Some(0)),
557            _ => panic!(),
558        }
559    }
560
561    #[test]
562    fn datetime_with_offset() {
563        let v = ok(parse_date_time("2024-03-15T10:30:00-05:00"));
564        match v {
565            Value::DateTime(d) => assert_eq!(d.tz_min, Some(-300)),
566            _ => panic!(),
567        }
568    }
569
570    #[test]
571    fn datetime_with_fractional_seconds() {
572        let v = ok(parse_date_time("2024-03-15T10:30:45.123Z"));
573        match v {
574            Value::DateTime(d) => assert_eq!(d.nanos, 123_000_000),
575            _ => panic!(),
576        }
577    }
578
579    #[test]
580    fn datetime_negative_year_allowed() {
581        assert!(parse_date_time("-0044-03-15T00:00:00Z").is_ok());
582    }
583
584    #[test]
585    fn datetime_year_zero_rejected() {
586        assert!(parse_date_time("0000-01-01T00:00:00Z").is_err());
587    }
588
589    #[test]
590    fn datetime_extended_year() {
591        assert!(parse_date_time("12345-01-01T00:00:00Z").is_ok());
592        // No leading zero on extended year.
593        assert!(parse_date_time("01234-01-01T00:00:00Z").is_err());
594    }
595
596    #[test]
597    fn datetime_24_00_00_allowed() {
598        assert!(parse_date_time("2024-03-15T24:00:00Z").is_ok());
599        // 24:30 is illegal.
600        assert!(parse_date_time("2024-03-15T24:30:00Z").is_err());
601    }
602
603    #[test]
604    fn datetime_invalid_day() {
605        assert!(parse_date_time("2024-02-30T00:00:00Z").is_err());
606        assert!(parse_date_time("2023-02-29T00:00:00Z").is_err()); // not leap
607        assert!(parse_date_time("2024-02-29T00:00:00Z").is_ok());  // leap
608    }
609
610    #[test]
611    fn datetime_tz_range() {
612        assert!(parse_date_time("2024-01-01T00:00:00+14:00").is_ok());
613        assert!(parse_date_time("2024-01-01T00:00:00-14:00").is_ok());
614        assert!(parse_date_time("2024-01-01T00:00:00+15:00").is_err());
615    }
616
617    // ── date ─────────────────────────────────────────────────────────
618
619    #[test]
620    fn date_basic() {
621        ok(parse_date("2024-03-15"));
622        ok(parse_date("2024-03-15Z"));
623        ok(parse_date("2024-03-15-08:00"));
624    }
625
626    #[test]
627    fn date_rejects_time_part() {
628        assert!(parse_date("2024-03-15T00:00:00").is_err());
629    }
630
631    // ── time ─────────────────────────────────────────────────────────
632
633    #[test]
634    fn time_basic() {
635        ok(parse_time("10:30:00"));
636        ok(parse_time("23:59:59.999Z"));
637    }
638
639    #[test]
640    fn time_rejects_24_anything_but_zero() {
641        assert!(parse_time("24:00:00").is_ok());
642        assert!(parse_time("24:00:01").is_err());
643        assert!(parse_time("25:00:00").is_err());
644    }
645
646    // ── gYearMonth / gYear / gMonthDay / gDay / gMonth ──────────────
647
648    #[test]
649    fn g_year_month() {
650        ok(parse_g_year_month("2024-03"));
651        ok(parse_g_year_month("2024-03Z"));
652        assert!(parse_g_year_month("2024-13").is_err());
653    }
654
655    #[test]
656    fn g_year() {
657        ok(parse_g_year("2024"));
658        ok(parse_g_year("-0044"));
659        ok(parse_g_year("12345Z"));
660    }
661
662    #[test]
663    fn g_month_day() {
664        ok(parse_g_month_day("--03-15"));
665        ok(parse_g_month_day("--02-29")); // permitted (leap year reference)
666        assert!(parse_g_month_day("03-15").is_err());        // missing leading --
667        assert!(parse_g_month_day("--13-01").is_err());      // bad month
668    }
669
670    #[test]
671    fn g_day() {
672        ok(parse_g_day("---15"));
673        ok(parse_g_day("---01Z"));
674        assert!(parse_g_day("--15").is_err());               // wrong number of dashes
675    }
676
677    #[test]
678    fn g_month() {
679        ok(parse_g_month("--12"));
680        ok(parse_g_month("--01-08:00"));
681        assert!(parse_g_month("12").is_err());
682    }
683
684    // ── duration ─────────────────────────────────────────────────────
685
686    #[test]
687    fn duration_basic() {
688        let v = ok(parse_duration("P1Y2M3DT4H5M6S"));
689        match v {
690            Value::Duration(d) => {
691                assert_eq!(d.months, 14);
692                assert_eq!(d.seconds, 3*86400 + 4*3600 + 5*60 + 6);
693                assert_eq!(d.nanos, 0);
694            }
695            _ => panic!(),
696        }
697    }
698
699    #[test]
700    fn duration_negative() {
701        let v = ok(parse_duration("-P1D"));
702        match v {
703            Value::Duration(d) => assert_eq!(d.seconds, -86400),
704            _ => panic!(),
705        }
706    }
707
708    #[test]
709    fn duration_partial() {
710        ok(parse_duration("PT1H"));
711        ok(parse_duration("P1Y"));
712        ok(parse_duration("PT0.5S"));
713    }
714
715    #[test]
716    fn duration_rejects_empty() {
717        assert!(parse_duration("P").is_err());
718        assert!(parse_duration("PT").is_err());
719        assert!(parse_duration("").is_err());
720    }
721
722    #[test]
723    fn duration_fractional_seconds() {
724        let v = ok(parse_duration("PT0.001S"));
725        match v {
726            Value::Duration(d) => {
727                assert_eq!(d.seconds, 0);
728                assert_eq!(d.nanos, 1_000_000);
729            }
730            _ => panic!(),
731        }
732    }
733
734    // ── ordering ─────────────────────────────────────────────────────
735
736    #[test]
737    fn datetime_ordering_with_tz() {
738        let earlier = match parse_date_time("2024-01-01T00:00:00Z").unwrap() {
739            Value::DateTime(d) => d, _ => unreachable!()
740        };
741        let later = match parse_date_time("2024-01-02T00:00:00Z").unwrap() {
742            Value::DateTime(d) => d, _ => unreachable!()
743        };
744        assert!(earlier < later);
745    }
746
747    #[test]
748    fn datetime_ordering_normalizes_tz() {
749        let utc_noon  = match parse_date_time("2024-01-01T12:00:00Z").unwrap() {
750            Value::DateTime(d) => d, _ => unreachable!()
751        };
752        // 09:00 -03:00 = 12:00 UTC — same instant.
753        let other  = match parse_date_time("2024-01-01T09:00:00-03:00").unwrap() {
754            Value::DateTime(d) => d, _ => unreachable!()
755        };
756        assert_eq!(utc_noon.cmp(&other), Ordering::Equal);
757    }
758}