Skip to main content

citum_edtf/
lib.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! `citum_edtf` - A modern EDTF (Extended Date/Time Format) parser
7//!
8//! This crate implements ISO 8601-2:2019 (EDTF) Level 0 and Level 1.
9
10use winnow::ascii::dec_int;
11use winnow::combinator::{alt, opt};
12use winnow::error::{ContextError, ErrMode};
13use winnow::prelude::*;
14use winnow::token::take;
15
16#[cfg(feature = "serde")]
17use serde::{Deserialize, Serialize};
18
19/// Represents the top-level EDTF value.
20#[derive(Debug, PartialEq, Eq, Clone)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22pub enum Edtf {
23    /// A single date.
24    Date(Date),
25    /// A date interval.
26    Interval(Interval),
27    /// An open-ended interval starting at a specific date.
28    IntervalFrom(Date),
29    /// An open-ended interval ending at a specific date.
30    IntervalTo(Date),
31}
32
33impl Edtf {
34    /// Extract the year component. For intervals, this is the start year.
35    pub fn year(&self) -> i64 {
36        match self {
37            Self::Date(date) => date.year.value,
38            Self::Interval(interval) => interval.start.year.value,
39            Self::IntervalFrom(date) => date.year.value,
40            Self::IntervalTo(date) => date.year.value,
41        }
42    }
43
44    /// Extract the month component if present. For intervals, this is the start month.
45    pub fn month(&self) -> Option<u32> {
46        let m_opt = match self {
47            Self::Date(date) => date.month_or_season,
48            Self::Interval(interval) => interval.start.month_or_season,
49            Self::IntervalFrom(date) => date.month_or_season,
50            Self::IntervalTo(date) => date.month_or_season,
51        };
52        match m_opt {
53            Some(MonthOrSeason::Month(m)) => Some(m),
54            _ => None,
55        }
56    }
57
58    /// Extract the day component if present. For intervals, this is the start day.
59    pub fn day(&self) -> Option<u32> {
60        let d_opt = match self {
61            Self::Date(date) => date.day,
62            Self::Interval(interval) => interval.start.day,
63            Self::IntervalFrom(date) => date.day,
64            Self::IntervalTo(date) => date.day,
65        };
66        match d_opt {
67            Some(Day::Day(d)) => Some(d),
68            _ => None,
69        }
70    }
71
72    /// Check if this is a range (interval).
73    pub fn is_range(&self) -> bool {
74        matches!(
75            self,
76            Self::Interval(_) | Self::IntervalFrom(_) | Self::IntervalTo(_)
77        )
78    }
79
80    /// Check if the range is open-ended (ends with "..").
81    pub fn is_open_range(&self) -> bool {
82        matches!(self, Self::IntervalFrom(_))
83    }
84
85    /// Extract the time component from the date, if present.
86    pub fn time(&self) -> Option<Time> {
87        match self {
88            Self::Date(date) => date.time,
89            _ => None,
90        }
91    }
92
93    /// Check if the date has a time component.
94    pub fn has_time(&self) -> bool {
95        self.time().is_some()
96    }
97}
98
99/// A date interval.
100#[derive(Debug, PartialEq, Eq, Clone)]
101#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
102pub struct Interval {
103    /// The starting date of the interval.
104    pub start: Date,
105    /// The ending date of the interval.
106    pub end: Date,
107}
108
109/// Represents either a calendar month or an EDTF Level 1 season code.
110#[derive(Debug, PartialEq, Eq, Clone, Copy)]
111#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
112pub enum MonthOrSeason {
113    /// A calendar month value from `1` through `12`.
114    Month(u32),
115    /// An unspecified month marker such as `uu` or `XX`.
116    Unspecified,
117    /// The EDTF season code `21`.
118    Spring,
119    /// The EDTF season code `22`.
120    Summer,
121    /// The EDTF season code `23`.
122    Autumn,
123    /// The EDTF season code `24`.
124    Winter,
125}
126
127/// Records EDTF uncertainty and approximation qualifiers for one date component.
128#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
129#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
130pub struct Quality {
131    /// Whether the component is marked uncertain with `?`.
132    pub uncertain: bool,
133    /// Whether the component is marked approximate with `~`.
134    pub approximate: bool,
135}
136
137/// A year in an EDTF date, which may contain unspecified digits.
138#[derive(Debug, PartialEq, Eq, Clone, Copy)]
139#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
140pub struct Year {
141    /// The numeric year value with unspecified digits normalized to `0`.
142    pub value: i64,
143    /// The number of trailing unspecified digits represented in the source.
144    pub unspecified: UnspecifiedYear,
145}
146
147/// Unspecified digits in a year (EDTF Level 1).
148#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
149#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
150pub enum UnspecifiedYear {
151    #[default]
152    /// The year is fully specified.
153    None,
154    /// One unspecified digit (e.g., 199u)
155    One,
156    /// Two unspecified digits (e.g., 19uu)
157    Two,
158    /// Three unspecified digits (e.g., 1uuu)
159    Three,
160    /// Four unspecified digits (e.g., uuuu)
161    Four,
162}
163
164/// A day in an EDTF date, which may be unspecified.
165#[derive(Debug, PartialEq, Eq, Clone, Copy)]
166#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
167pub enum Day {
168    /// A calendar day value from `1` through `31`.
169    Day(u32),
170    /// An unspecified day marker such as `uu` or `XX`.
171    Unspecified,
172}
173
174/// Stores a parsed EDTF date or datetime with per-component quality markers.
175#[derive(Debug, PartialEq, Eq, Clone)]
176#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
177pub struct Date {
178    /// The parsed year component.
179    pub year: Year,
180    /// Qualifiers that apply to the year component.
181    pub year_quality: Quality,
182    /// The parsed month or season component, if present.
183    pub month_or_season: Option<MonthOrSeason>,
184    /// Qualifiers that apply to the month or season component.
185    pub month_quality: Quality,
186    /// The parsed day component, if present.
187    pub day: Option<Day>,
188    /// Qualifiers that apply to the day component.
189    pub day_quality: Quality,
190    /// The parsed time component, if present.
191    pub time: Option<Time>,
192}
193
194/// Timezone specification for an EDTF datetime.
195#[derive(Debug, PartialEq, Eq, Clone, Copy)]
196#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
197pub enum Timezone {
198    /// UTC (Z suffix)
199    Utc,
200    /// Offset in minutes from UTC (positive = east, negative = west)
201    Offset(i16),
202}
203
204/// Stores a basic ISO 8601 time with an optional timezone offset.
205#[derive(Debug, PartialEq, Eq, Clone, Copy)]
206#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
207pub struct Time {
208    /// The hour component in the range `0..=23`.
209    pub hour: u32,
210    /// The minute component in the range `0..=59`.
211    pub minute: u32,
212    /// The second component in the range `0..=59`.
213    pub second: u32,
214    /// The parsed timezone designator, if present.
215    pub timezone: Option<Timezone>,
216}
217
218use std::fmt;
219
220/// Error returned when an EDTF string cannot be parsed.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ParseError(String);
223
224impl fmt::Display for ParseError {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "invalid EDTF: {}", self.0)
227    }
228}
229
230impl std::error::Error for ParseError {}
231
232impl std::str::FromStr for Edtf {
233    type Err = ParseError;
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        let mut input = s;
237        let edtf = parse(&mut input).map_err(|e| ParseError(e.to_string()))?;
238        if !input.is_empty() {
239            return Err(ParseError(format!("unexpected trailing input: {input}")));
240        }
241        Ok(edtf)
242    }
243}
244
245impl std::str::FromStr for Date {
246    type Err = ParseError;
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        let mut input = s;
250        let date = parse_date(&mut input).map_err(|e| ParseError(e.to_string()))?;
251        if !input.is_empty() {
252            return Err(ParseError(format!("unexpected trailing input: {input}")));
253        }
254        Ok(date)
255    }
256}
257
258impl fmt::Display for Edtf {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        match self {
261            Edtf::Date(d) => write!(f, "{d}"),
262            Edtf::Interval(i) => write!(f, "{}/{}", i.start, i.end),
263            Edtf::IntervalFrom(d) => write!(f, "{d}/.."),
264            Edtf::IntervalTo(d) => write!(f, "../{d}"),
265        }
266    }
267}
268
269impl fmt::Display for Date {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        write!(f, "{}{}", self.year, self.year_quality)?;
272        if let Some(m) = self.month_or_season {
273            write!(f, "-{}{}", m, self.month_quality)?;
274            if let Some(d) = self.day {
275                write!(f, "-{}{}", d, self.day_quality)?;
276            }
277        }
278        if let Some(t) = self.time {
279            write!(f, "T{t}")?;
280        }
281        Ok(())
282    }
283}
284
285impl fmt::Display for Year {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        if self.value > 9999 || self.value < -9999 {
288            write!(f, "Y{}", self.value)
289        } else if self.value < 0 {
290            let abs_val = self.value.abs();
291            let mut s = format!("{abs_val:04}");
292            match self.unspecified {
293                UnspecifiedYear::None => write!(f, "-{s}"),
294                UnspecifiedYear::One => {
295                    s.replace_range(3..4, "u");
296                    write!(f, "-{s}")
297                }
298                UnspecifiedYear::Two => {
299                    s.replace_range(2..4, "uu");
300                    write!(f, "-{s}")
301                }
302                UnspecifiedYear::Three => {
303                    s.replace_range(1..4, "uuu");
304                    write!(f, "-{s}")
305                }
306                UnspecifiedYear::Four => {
307                    s.replace_range(0..4, "uuuu");
308                    write!(f, "-{s}")
309                }
310            }
311        } else {
312            let mut s = format!("{:04}", self.value);
313            match self.unspecified {
314                UnspecifiedYear::None => write!(f, "{s}"),
315                UnspecifiedYear::One => {
316                    s.replace_range(3..4, "u");
317                    write!(f, "{s}")
318                }
319                UnspecifiedYear::Two => {
320                    s.replace_range(2..4, "uu");
321                    write!(f, "{s}")
322                }
323                UnspecifiedYear::Three => {
324                    s.replace_range(1..4, "uuu");
325                    write!(f, "{s}")
326                }
327                UnspecifiedYear::Four => {
328                    s.replace_range(0..4, "uuuu");
329                    write!(f, "{s}")
330                }
331            }
332        }
333    }
334}
335
336impl fmt::Display for MonthOrSeason {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        match self {
339            MonthOrSeason::Month(m) => write!(f, "{m:02}"),
340            MonthOrSeason::Unspecified => write!(f, "uu"),
341            MonthOrSeason::Spring => write!(f, "21"),
342            MonthOrSeason::Summer => write!(f, "22"),
343            MonthOrSeason::Autumn => write!(f, "23"),
344            MonthOrSeason::Winter => write!(f, "24"),
345        }
346    }
347}
348
349impl fmt::Display for Day {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        match self {
352            Day::Day(d) => write!(f, "{d:02}"),
353            Day::Unspecified => write!(f, "uu"),
354        }
355    }
356}
357
358impl fmt::Display for Time {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
361        match self.timezone {
362            Some(Timezone::Utc) => write!(f, "Z"),
363            Some(Timezone::Offset(mins)) => {
364                let sign = if mins >= 0 { '+' } else { '-' };
365                let abs = mins.unsigned_abs();
366                write!(f, "{}{:02}:{:02}", sign, abs / 60, abs % 60)
367            }
368            None => Ok(()),
369        }
370    }
371}
372
373impl fmt::Display for Quality {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match (self.uncertain, self.approximate) {
376            (true, true) => write!(f, "%"),
377            (true, false) => write!(f, "?"),
378            (false, true) => write!(f, "~"),
379            (false, false) => Ok(()),
380        }
381    }
382}
383
384fn parse_quality(input: &mut &str) -> Result<Quality, ErrMode<ContextError>> {
385    let qualifier = opt(alt(('?', '~', '%'))).parse_next(input)?;
386    Ok(match qualifier {
387        Some('?') => Quality {
388            uncertain: true,
389            approximate: false,
390        },
391        Some('~') => Quality {
392            uncertain: false,
393            approximate: true,
394        },
395        Some('%') => Quality {
396            uncertain: true,
397            approximate: true,
398        },
399        _ => Quality::default(),
400    })
401}
402
403fn parse_year(input: &mut &str) -> Result<Year, ErrMode<ContextError>> {
404    if input.starts_with('Y') {
405        let _ = 'Y'.parse_next(input)?;
406        let value: i64 = dec_int.parse_next(input)?;
407        return Ok(Year {
408            value,
409            unspecified: UnspecifiedYear::None,
410        });
411    }
412
413    let sign = opt(alt(('-', '+'))).parse_next(input)?;
414    let s = take(4_usize).parse_next(input)?;
415
416    let mut value_str = String::with_capacity(4);
417    let mut unspecified_count = 0;
418
419    for c in s.chars() {
420        if c == 'u' || c == 'X' {
421            value_str.push('0');
422            unspecified_count += 1;
423        } else if c.is_ascii_digit() {
424            value_str.push(c);
425        } else {
426            return Err(ErrMode::Backtrack(ContextError::default()));
427        }
428    }
429
430    let mut value = value_str
431        .parse::<i64>()
432        .map_err(|_| ErrMode::Backtrack(ContextError::default()))?;
433
434    if let Some('-') = sign {
435        value = -value;
436    }
437
438    let unspecified = match unspecified_count {
439        0 => UnspecifiedYear::None,
440        1 => UnspecifiedYear::One,
441        2 => UnspecifiedYear::Two,
442        3 => UnspecifiedYear::Three,
443        4 => UnspecifiedYear::Four,
444        _ => return Err(ErrMode::Backtrack(ContextError::default())),
445    };
446
447    Ok(Year { value, unspecified })
448}
449
450fn parse_month_or_season(input: &mut &str) -> Result<MonthOrSeason, ErrMode<ContextError>> {
451    let s = take(2_usize).parse_next(input)?;
452    if s == "uu" || s == "XX" {
453        return Ok(MonthOrSeason::Unspecified);
454    }
455
456    let val: u32 = s
457        .parse()
458        .map_err(|_| ErrMode::Backtrack(ContextError::default()))?;
459
460    match val {
461        1..=12 => Ok(MonthOrSeason::Month(val)),
462        21 => Ok(MonthOrSeason::Spring),
463        22 => Ok(MonthOrSeason::Summer),
464        23 => Ok(MonthOrSeason::Autumn),
465        24 => Ok(MonthOrSeason::Winter),
466        _ => Err(ErrMode::Backtrack(ContextError::default())),
467    }
468}
469
470fn parse_day(input: &mut &str) -> Result<Day, ErrMode<ContextError>> {
471    let s = take(2_usize).parse_next(input)?;
472    if s == "uu" || s == "XX" {
473        return Ok(Day::Unspecified);
474    }
475
476    let val: u32 = s
477        .parse()
478        .map_err(|_| ErrMode::Backtrack(ContextError::default()))?;
479    match val {
480        1..=31 => Ok(Day::Day(val)),
481        _ => Err(ErrMode::Backtrack(ContextError::default())),
482    }
483}
484
485fn parse_timezone(input: &mut &str) -> Result<Option<Timezone>, ErrMode<ContextError>> {
486    if input.starts_with('Z') {
487        let _ = 'Z'.parse_next(input)?;
488        return Ok(Some(Timezone::Utc));
489    }
490    if input.starts_with('+') || input.starts_with('-') {
491        let sign = opt(alt(('+', '-'))).parse_next(input)?.unwrap_or('+');
492        let h = take(2_usize)
493            .try_map(|s: &str| s.parse::<i16>())
494            .parse_next(input)?;
495        let _ = ':'.parse_next(input)?;
496        let m = take(2_usize)
497            .try_map(|s: &str| s.parse::<i16>())
498            .parse_next(input)?;
499        let total = h * 60 + m;
500        let offset = if sign == '-' { -total } else { total };
501        return Ok(Some(Timezone::Offset(offset)));
502    }
503    Ok(None)
504}
505
506fn parse_time(input: &mut &str) -> Result<Time, ErrMode<ContextError>> {
507    let hour = take(2_usize)
508        .try_map(|s: &str| s.parse::<u32>())
509        .parse_next(input)?;
510    let _ = ':'.parse_next(input)?;
511    let minute = take(2_usize)
512        .try_map(|s: &str| s.parse::<u32>())
513        .parse_next(input)?;
514    let _ = ':'.parse_next(input)?;
515    let second = take(2_usize)
516        .try_map(|s: &str| s.parse::<u32>())
517        .parse_next(input)?;
518    let timezone = parse_timezone(input)?;
519
520    if hour > 23 || minute > 59 || second > 59 {
521        return Err(ErrMode::Backtrack(ContextError::default()));
522    }
523
524    Ok(Time {
525        hour,
526        minute,
527        second,
528        timezone,
529    })
530}
531
532/// Parses one EDTF date or datetime from the start of `input`.
533///
534/// This parser consumes only the recognized prefix and leaves any remaining
535/// input in `input`, following `winnow` parser conventions.
536///
537/// # Errors
538///
539/// Returns an error when the input does not begin with a valid EDTF date or
540/// datetime production.
541pub fn parse_date(input: &mut &str) -> Result<Date, ErrMode<ContextError>> {
542    let year = parse_year.parse_next(input)?;
543    let year_quality = parse_quality.parse_next(input)?;
544
545    let month_or_season = if input.starts_with('-') {
546        let _ = '-'.parse_next(input)?;
547        Some(parse_month_or_season.parse_next(input)?)
548    } else {
549        None
550    };
551    let month_quality = if month_or_season.is_some() {
552        parse_quality.parse_next(input)?
553    } else {
554        Quality::default()
555    };
556
557    let day = if let Some(MonthOrSeason::Month(_) | MonthOrSeason::Unspecified) = month_or_season {
558        if input.starts_with('-') {
559            let _ = '-'.parse_next(input)?;
560            Some(parse_day.parse_next(input)?)
561        } else {
562            None
563        }
564    } else {
565        None
566    };
567    let day_quality = if day.is_some() {
568        parse_quality.parse_next(input)?
569    } else {
570        Quality::default()
571    };
572
573    let time = if input.starts_with('T') {
574        let _ = 'T'.parse_next(input)?;
575        Some(parse_time.parse_next(input)?)
576    } else {
577        None
578    };
579
580    // Final check: if the last component parsed didn't have a quality marker,
581    // but there is one at the end of the string, it applies to the whole thing?
582    // Actually, ISO 8601-2 says it applies to what's on the left.
583    // If we have "2004-06-11?", it applies to "11".
584
585    Ok(Date {
586        year,
587        year_quality,
588        month_or_season,
589        month_quality,
590        day,
591        day_quality,
592        time,
593    })
594}
595
596/// Parses one top-level EDTF value from the start of `input`.
597///
598/// This parser accepts a single date, a closed interval, or an open-ended
599/// interval and leaves any unconsumed suffix in `input`.
600///
601/// # Errors
602///
603/// Returns an error when the input does not begin with a valid EDTF date or
604/// interval production.
605pub fn parse(input: &mut &str) -> Result<Edtf, ErrMode<ContextError>> {
606    if input.starts_with("../") {
607        let _ = "../".parse_next(input)?;
608        let date = parse_date.parse_next(input)?;
609        return Ok(Edtf::IntervalTo(date));
610    }
611
612    let start_date = parse_date.parse_next(input)?;
613
614    if input.starts_with('/') {
615        let _ = '/'.parse_next(input)?;
616        if input.is_empty() || *input == ".." {
617            if *input == ".." {
618                let _ = "..".parse_next(input)?;
619            }
620            Ok(Edtf::IntervalFrom(start_date))
621        } else {
622            let end_date = parse_date.parse_next(input)?;
623            Ok(Edtf::Interval(Interval {
624                start: start_date,
625                end: end_date,
626            }))
627        }
628    } else {
629        Ok(Edtf::Date(start_date))
630    }
631}
632
633#[cfg(test)]
634#[allow(
635    clippy::unwrap_used,
636    clippy::expect_used,
637    clippy::panic,
638    clippy::indexing_slicing,
639    clippy::todo,
640    clippy::unimplemented,
641    clippy::unreachable,
642    clippy::get_unwrap,
643    reason = "Panicking is acceptable and often desired in tests."
644)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn test_parse_date() {
650        let mut input = "2023-05-15";
651        let res = parse_date(&mut input).unwrap();
652        assert_eq!(res.year.value, 2023);
653        assert_eq!(res.month_or_season, Some(MonthOrSeason::Month(5)));
654        assert_eq!(res.day, Some(Day::Day(15)));
655    }
656
657    #[test]
658    fn test_unspecified_year() {
659        let mut input = "199u";
660        let res = parse_date(&mut input).unwrap();
661        assert_eq!(res.year.value, 1990);
662        assert_eq!(res.year.unspecified, UnspecifiedYear::One);
663    }
664
665    #[test]
666    fn test_extended_year() {
667        let mut input = "Y17000000002";
668        let res = parse_date(&mut input).unwrap();
669        assert_eq!(res.year.value, 17_000_000_002_i64);
670    }
671
672    #[test]
673    fn test_unspecified_month_day() {
674        let mut input = "2004-uu-uu";
675        let res = parse_date(&mut input).unwrap();
676        assert_eq!(res.month_or_season, Some(MonthOrSeason::Unspecified));
677        assert_eq!(res.day, Some(Day::Unspecified));
678    }
679
680    #[test]
681    fn test_component_quality() {
682        let mut input = "2004?-06-11";
683        let res = parse_date(&mut input).unwrap();
684        assert!(res.year_quality.uncertain);
685        assert!(!res.month_quality.uncertain);
686        assert!(!res.day_quality.uncertain);
687
688        let mut input2 = "2004-06-11?";
689        let res2 = parse_date(&mut input2).unwrap();
690        assert!(!res2.year_quality.uncertain);
691        assert!(!res2.month_quality.uncertain);
692        assert!(res2.day_quality.uncertain);
693    }
694
695    #[test]
696    fn test_parse_interval() {
697        let mut input = "2023-05/2024-06";
698        let res = parse(&mut input).unwrap();
699        if let Edtf::Interval(interval) = res {
700            assert_eq!(interval.start.year.value, 2023);
701            assert_eq!(interval.end.year.value, 2024);
702        } else {
703            panic!("Expected Interval");
704        }
705    }
706
707    #[test]
708    fn test_parse_interval_from() {
709        let mut input = "2023-05/..";
710        let res = parse(&mut input).unwrap();
711        if let Edtf::IntervalFrom(date) = res {
712            assert_eq!(date.year.value, 2023);
713        } else {
714            panic!("Expected IntervalFrom");
715        }
716    }
717
718    #[test]
719    fn test_parse_interval_to() {
720        let mut input = "../2023-05";
721        let res = parse(&mut input).unwrap();
722        if let Edtf::IntervalTo(date) = res {
723            assert_eq!(date.year.value, 2023);
724            assert_eq!(date.month_or_season, Some(MonthOrSeason::Month(5)));
725        } else {
726            panic!("Expected IntervalTo");
727        }
728    }
729
730    #[test]
731    fn test_parse_season() {
732        let mut input = "2023-21";
733        let res = parse_date(&mut input).unwrap();
734        assert_eq!(res.month_or_season, Some(MonthOrSeason::Spring));
735        assert_eq!(res.to_string(), "2023-21");
736    }
737
738    #[test]
739    fn test_round_trip() {
740        let cases = vec![
741            "2023-05-15",
742            "199u",
743            "2004-uu-uu",
744            "2004?-06-11",
745            "2004-06-11?",
746            "2023-05/2024-06",
747            "2023-05/..",
748            "../2023-05",
749            "Y17000000002",
750            "1985-04-12T23:20:30Z",
751            "2004-01-01T10:10:10+05:30",
752        ];
753        for case in cases {
754            let mut input = case;
755            let res = parse(&mut input).unwrap();
756            assert_eq!(res.to_string(), case);
757        }
758    }
759
760    #[test]
761    fn test_parse_datetime_utc() {
762        let mut input = "1985-04-12T23:20:30Z";
763        let res = parse_date(&mut input).unwrap();
764        let t = res.time.unwrap();
765        assert_eq!(t.hour, 23);
766        assert_eq!(t.minute, 20);
767        assert_eq!(t.second, 30);
768        assert_eq!(t.timezone, Some(Timezone::Utc));
769    }
770
771    #[test]
772    fn test_parse_datetime_offset() {
773        let mut input = "2004-01-01T10:10:10+05:30";
774        let res = parse_date(&mut input).unwrap();
775        let t = res.time.unwrap();
776        assert_eq!(t.timezone, Some(Timezone::Offset(330)));
777    }
778
779    #[test]
780    fn test_parse_datetime_no_tz() {
781        let mut input = "2004-01-01T10:10:10";
782        let res = parse_date(&mut input).unwrap();
783        let t = res.time.unwrap();
784        assert_eq!(t.timezone, None);
785    }
786
787    #[test]
788    fn test_parse_leaves_unconsumed_suffix() {
789        let mut input = "2023-05 trailing";
790        let res = parse(&mut input).unwrap();
791        assert_eq!(res.to_string(), "2023-05");
792        assert_eq!(input, " trailing");
793    }
794
795    #[test]
796    fn test_invalid_day_is_rejected() {
797        let mut input = "2023-05-32";
798        assert!(parse_date(&mut input).is_err());
799    }
800
801    #[test]
802    fn test_invalid_time_is_rejected() {
803        let mut invalid_hour = "2023-05-15T24:00:00";
804        assert!(parse_date(&mut invalid_hour).is_err());
805
806        let mut invalid_minute = "2023-05-15T23:60:00";
807        assert!(parse_date(&mut invalid_minute).is_err());
808
809        let mut invalid_second = "2023-05-15T23:59:60";
810        assert!(parse_date(&mut invalid_second).is_err());
811    }
812}