Skip to main content

marque_ism/
date.rs

1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5//! ISM date precision-tier types modeling the XSD `ISO8601DateTimeType` union.
6//!
7//! The ISM XSD defines declassification and other date attributes as a *union*
8//! of ISO 8601 precision tiers (see `IC-ISM.xsd`):
9//!
10//! ```text
11//! ISO8601DateTimeType = dateTime | dateHourMinType | date | gYearMonth | gYear
12//! ```
13//!
14//! Each tier represents a temporal *span*, not a point:
15//! - `xsd:gYear` "2003" spans the entire calendar year.
16//! - `xsd:gYearMonth` "2003-04" spans the entire month of April 2003.
17//! - `xsd:date` "2003-04-15" spans a single calendar day.
18//! - `dateHourMinType` spans a single minute (HH:MM, no seconds).
19//! - `xsd:dateTime` spans a single instant (seconds + fractional seconds).
20//!
21//! Use [`IsmDate::contains`] for span-containment checks. Total ordering
22//! (`<`) is deliberately **not** implemented because the semantics are
23//! ambiguous across precision tiers ("is `Year(2003)` less than
24//! `Date(2003-04-15)`?"). For lattice-max operations (`MaxDate`), use
25//! [`IsmDate::end_cmp`], which compares the end-of-span instants.
26//!
27//! # Schema observations
28//!
29//! - `dateHourMinType`'s doc text says "includes seconds/milliseconds"
30//!   but the regex restricts to `HH:MM` only. The regex is authoritative.
31//! - All zoned types support optional `Z` or `±HH:MM` offset.
32//! - `xsd:gYear` / `xsd:gYearMonth` are date-only with no time component.
33//! - [`ApproxQualifier`] is a companion axis from `DateApproximationVocabType`,
34//!   not a member of the main union. Combine with an `IsmDate` in
35//!   [`ApproxIsmDate`].
36//!
37//! # WASM safety
38//!
39//! All types are WASM-safe. The underlying `jiff` calendar arithmetic uses
40//! `features = ["std"]` (no tzdb I/O) and `jiff::civil` types that work on
41//! `wasm32-unknown-unknown`.
42
43use std::cmp::Ordering;
44use std::fmt;
45use std::str::FromStr;
46
47use jiff::civil;
48
49// ---------------------------------------------------------------------------
50// UtcOffset
51// ---------------------------------------------------------------------------
52
53/// A UTC offset suitable for `DateHourMin` and `DateTime` precision tiers.
54///
55/// Stored as signed integer minutes from UTC in the range −1439..=+1439
56/// (i.e. ±23:59). The maximum representable offset is ±23:59; offsets of
57/// ±24:00 or larger are rejected by [`UtcOffset::from_hhmm`].
58/// `None` in the parent type represents a *floating* (offset-naive) time.
59///
60/// # Examples
61///
62/// ```
63/// use marque_ism::date::UtcOffset;
64///
65/// let eastern = UtcOffset::from_hhmm(-1, 5, 0).unwrap(); // -05:00
66/// assert_eq!(eastern.to_string(), "-05:00");
67///
68/// assert_eq!(UtcOffset::UTC.to_string(), "Z");
69/// ```
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct UtcOffset {
72    /// Signed offset in minutes: +05:30 → 330, −07:00 → −420.
73    pub minutes: i16,
74}
75
76impl UtcOffset {
77    /// UTC (zero offset).
78    pub const UTC: Self = Self { minutes: 0 };
79
80    /// Construct from a sign and hours/minutes.
81    ///
82    /// `sign` must be `1` or `-1`. Returns `None` if components are out of
83    /// range (`hours > 23`, `minutes > 59`). The maximum representable
84    /// offset magnitude is 23:59 (1439 minutes).
85    pub fn from_hhmm(sign: i8, hours: u8, minutes: u8) -> Option<Self> {
86        if !matches!(sign, 1 | -1) || hours > 23 || minutes > 59 {
87            return None;
88        }
89        let total = (hours as i16 * 60 + minutes as i16) * sign as i16;
90        Some(Self { minutes: total })
91    }
92
93    /// Total offset in seconds (for jiff `tz::Offset::from_seconds`).
94    pub fn to_seconds(self) -> i32 {
95        self.minutes as i32 * 60
96    }
97}
98
99impl fmt::Display for UtcOffset {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        if self.minutes == 0 {
102            write!(f, "Z")
103        } else {
104            let sign = if self.minutes >= 0 { '+' } else { '-' };
105            let abs = self.minutes.unsigned_abs();
106            write!(f, "{}{:02}:{:02}", sign, abs / 60, abs % 60)
107        }
108    }
109}
110
111// ---------------------------------------------------------------------------
112// IsmDate
113// ---------------------------------------------------------------------------
114
115/// ISM date precision-tier union, mirroring `ISO8601DateTimeType`.
116///
117/// Each variant represents the span for its precision tier:
118///
119/// | Variant | XSD type | Span |
120/// |---------|----------|------|
121/// | [`Year`] | `xsd:gYear` | entire calendar year |
122/// | [`YearMonth`] | `xsd:gYearMonth` | entire calendar month |
123/// | [`Date`] | `xsd:date` | single calendar day |
124/// | [`DateHourMin`] | `dateHourMinType` | single minute (HH:MM, no seconds) |
125/// | [`DateTime`] | `xsd:dateTime` | precise instant |
126///
127/// # Parsing
128///
129/// `IsmDate` implements [`FromStr`]. Accepted forms:
130///
131/// | Input | Variant |
132/// |-------|---------|
133/// | `YYYY` (e.g. `2003`) | `Year` |
134/// | `YYYY-MM` (e.g. `2003-04`) | `YearMonth` |
135/// | `YYYY-MM-DD` (e.g. `2003-04-15`) | `Date` |
136/// | `YYYYMMDD` (CAPCO no-hyphen form, e.g. `20030415`) | `Date` |
137/// | `YYYY-MM-DDTHH:MM`, optionally `Z` or `±HH:MM` | `DateHourMin` |
138/// | `YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]` | `DateTime` |
139///
140/// # Display
141///
142/// [`fmt::Display`] produces canonical ISO 8601 form (with hyphens and `T`
143/// separator) suitable for round-trip: `IsmDate::from_str(&date.to_string())
144/// == Ok(date)`.
145///
146/// [`Year`]: IsmDate::Year
147/// [`YearMonth`]: IsmDate::YearMonth
148/// [`Date`]: IsmDate::Date
149/// [`DateHourMin`]: IsmDate::DateHourMin
150/// [`DateTime`]: IsmDate::DateTime
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152pub enum IsmDate {
153    /// `xsd:gYear` — e.g. `"2003"`. Represents the span Jan 1 – Dec 31 of
154    /// the given year (inclusive).
155    Year(i32),
156
157    /// `xsd:gYearMonth` — e.g. `"2003-04"`. Represents the entire month in
158    /// the given year.
159    YearMonth(i32, u8),
160
161    /// `xsd:date` — e.g. `"2003-04-15"`. A single calendar day.
162    ///
163    /// Also accepts the CAPCO no-hyphen form `"YYYYMMDD"` on input;
164    /// [`Display`] always produces `"YYYY-MM-DD"`.
165    ///
166    /// [`Display`]: fmt::Display
167    Date(i32, u8, u8),
168
169    /// `dateHourMinType` — e.g. `"2003-04-15T14:30Z"`.
170    ///
171    /// Date + hour + minute with optional UTC offset. The XSD regex restricts
172    /// to HH:MM only (no seconds or fractional seconds); sub-minute precision
173    /// is represented by [`DateTime`] instead.
174    ///
175    /// [`DateTime`]: IsmDate::DateTime
176    DateHourMin {
177        year: i32,
178        month: u8,
179        day: u8,
180        hour: u8,
181        minute: u8,
182        /// `None` for floating (offset-naive) time.
183        offset: Option<UtcOffset>,
184    },
185
186    /// `xsd:dateTime` — full ISO 8601 with seconds, optional fractional
187    /// seconds, and optional UTC offset.
188    DateTime {
189        year: i32,
190        month: u8,
191        day: u8,
192        hour: u8,
193        minute: u8,
194        second: u8,
195        /// Fractional seconds as nanoseconds (0..=999_999_999).
196        nanosecond: u32,
197        /// `None` for floating (offset-naive) time.
198        offset: Option<UtcOffset>,
199    },
200}
201
202impl IsmDate {
203    // -----------------------------------------------------------------------
204    // Component accessors
205    // -----------------------------------------------------------------------
206
207    /// The year component, always present.
208    #[inline]
209    pub fn year(&self) -> i32 {
210        match self {
211            IsmDate::Year(y) => *y,
212            IsmDate::YearMonth(y, _) => *y,
213            IsmDate::Date(y, _, _) => *y,
214            IsmDate::DateHourMin { year, .. } => *year,
215            IsmDate::DateTime { year, .. } => *year,
216        }
217    }
218
219    /// Month component, if present (1–12).
220    #[inline]
221    pub fn month(&self) -> Option<u8> {
222        match self {
223            IsmDate::Year(_) => None,
224            IsmDate::YearMonth(_, m) => Some(*m),
225            IsmDate::Date(_, m, _) => Some(*m),
226            IsmDate::DateHourMin { month, .. } => Some(*month),
227            IsmDate::DateTime { month, .. } => Some(*month),
228        }
229    }
230
231    /// Day component, if present (1–31).
232    #[inline]
233    pub fn day(&self) -> Option<u8> {
234        match self {
235            IsmDate::Year(_) | IsmDate::YearMonth(_, _) => None,
236            IsmDate::Date(_, _, d) => Some(*d),
237            IsmDate::DateHourMin { day, .. } => Some(*day),
238            IsmDate::DateTime { day, .. } => Some(*day),
239        }
240    }
241
242    // -----------------------------------------------------------------------
243    // Span containment
244    // -----------------------------------------------------------------------
245
246    /// Returns `true` if `point` falls within the temporal span this date
247    /// represents.
248    ///
249    /// Semantics: a coarser `IsmDate` (e.g. `Year(2003)`) represents a span
250    /// (all of 2003). A finer one (e.g. `Date(2003, 6, 15)`) represents a
251    /// narrower span. `self.contains(point)` is `true` iff the span of
252    /// `point` is entirely within the span of `self`.
253    ///
254    /// `Year(2003)` contains:
255    /// - `Year(2003)` ✓ (same span)
256    /// - `YearMonth(2003, 4)` ✓ (April 2003 ⊂ 2003)
257    /// - `Date(2003, 12, 31)` ✓
258    /// - `Date(2004, 1, 1)` ✗
259    ///
260    /// `YearMonth(2003, 4)` contains:
261    /// - `Year(2003)` ✗ (coarser than self)
262    /// - `Date(2003, 4, 1)` ✓
263    /// - `Date(2003, 5, 1)` ✗
264    ///
265    /// # Timezone handling
266    ///
267    /// Offsets are compared in their *represented* form, not after UTC
268    /// normalization. `DateHourMin { hour: 14, offset: UTC }` and
269    /// `DateHourMin { hour: 9, offset: -05:00 }` are the same civil instant
270    /// but `contains` does not normalize across offsets.
271    pub fn contains(&self, point: &IsmDate) -> bool {
272        // Year must always match.
273        if self.year() != point.year() {
274            return false;
275        }
276        match self {
277            IsmDate::Year(_) => {
278                // Any same-year date is contained.
279                true
280            }
281            IsmDate::YearMonth(_, sm) => {
282                // Point must have at least month precision and must be in the
283                // same month. A Year-only point is coarser than self.
284                match point {
285                    IsmDate::Year(_) => false,
286                    _ => point.month() == Some(*sm),
287                }
288            }
289            IsmDate::Date(_, sm, sd) => {
290                // Point must have at least day precision and must be on the
291                // same day.
292                match point {
293                    IsmDate::Year(_) | IsmDate::YearMonth(_, _) => false,
294                    _ => point.month() == Some(*sm) && point.day() == Some(*sd),
295                }
296            }
297            IsmDate::DateHourMin {
298                month: sm,
299                day: sd,
300                hour: sh,
301                minute: smin,
302                offset: soff,
303                ..
304            } => {
305                // Point must be at sub-day precision and match all components.
306                match point {
307                    IsmDate::DateHourMin {
308                        month,
309                        day,
310                        hour,
311                        minute,
312                        offset,
313                        ..
314                    } => month == sm && day == sd && hour == sh && minute == smin && offset == soff,
315                    IsmDate::DateTime {
316                        month,
317                        day,
318                        hour,
319                        minute,
320                        offset,
321                        ..
322                    } => month == sm && day == sd && hour == sh && minute == smin && offset == soff,
323                    _ => false,
324                }
325            }
326            IsmDate::DateTime {
327                month: sm,
328                day: sd,
329                hour: sh,
330                minute: smin,
331                second: ss,
332                nanosecond: sns,
333                offset: soff,
334                ..
335            } => {
336                if let IsmDate::DateTime {
337                    month,
338                    day,
339                    hour,
340                    minute,
341                    second,
342                    nanosecond,
343                    offset,
344                    ..
345                } = point
346                {
347                    month == sm
348                        && day == sd
349                        && hour == sh
350                        && minute == smin
351                        && second == ss
352                        && nanosecond == sns
353                        && offset == soff
354                } else {
355                    false
356                }
357            }
358        }
359    }
360
361    // -----------------------------------------------------------------------
362    // End-of-span comparison (for MaxDate lattice semantics)
363    // -----------------------------------------------------------------------
364
365    /// Compare two `IsmDate` values by their **end-of-span** instants.
366    ///
367    /// This is the correct comparator for the `MaxDate` lattice operation:
368    /// "which declassification date is the *latest* (most conservative)?"
369    ///
370    /// `Year(2003).end_cmp(Date(2003, 6, 15))` returns `Greater` because the
371    /// year 2003 extends through December 31, whereas June 15 ends earlier.
372    ///
373    /// Coarser spans fill in the *maximum* value for unspecified components:
374    /// - `Year(y)` end = (y, 12, 31, 23, 59, 59, 999_999_999)
375    /// - `YearMonth(y, m)` end = (y, m, last-day-of-month, 23, 59, 59, 999_999_999)
376    /// - `Date(y, m, d)` end = (y, m, d, 23, 59, 59, 999_999_999)
377    /// - `DateHourMin` end = (y, m, d, H, M, 59, 999_999_999)
378    /// - `DateTime` end = the precise instant
379    ///
380    /// When civil end-of-span components are equal, the value with the more
381    /// negative UTC offset (i.e. further behind UTC, representing a later UTC
382    /// instant) is considered Greater. For example, `2003-04-15T10:30-05:00`
383    /// (= 15:30 UTC) compares Greater than `2003-04-15T10:30Z` (= 10:30 UTC).
384    /// Floating (offset-naive) values treat offset as zero for tie-breaking.
385    pub fn end_cmp(&self, other: &IsmDate) -> Ordering {
386        let a = self.end_components();
387        let b = other.end_components();
388        a.cmp(&b)
389    }
390
391    /// Returns the end-of-span as a sortable `YYYYMMDD` string for use as a
392    /// MaxDate lattice key.
393    ///
394    /// The string is always 8 ASCII digits. Lex order on these strings is
395    /// chronological, so `MaxDate`'s lex join produces the correct
396    /// span-aware "latest" date.
397    ///
398    /// - `Year(y)` → `"{y:04}1231"` (December 31 of year)
399    /// - `YearMonth(y, m)` → last day of month
400    /// - `Date(y, m, d)` → `"{y:04}{m:02}{d:02}"`
401    /// - `DateHourMin / DateTime` → the date component only
402    pub fn to_maxdate_str(&self) -> Box<str> {
403        let (y, m, d, _, _, _, _, _) = self.end_components();
404        format!("{:04}{:02}{:02}", y, m, d).into_boxed_str()
405    }
406
407    // -----------------------------------------------------------------------
408    // Internal helpers
409    // -----------------------------------------------------------------------
410
411    /// End-of-span tuple `(year, month, day, hour, minute, second, nanosecond, utc_tie_break)`.
412    ///
413    /// Unspecified components are filled with their maximum values so that
414    /// `end_cmp` correctly orders coarser dates AFTER finer ones that fall
415    /// within the same span.
416    ///
417    /// The last element (`utc_tie_break`) is `-offset.minutes` (negated).
418    /// When all civil components are equal, a more negative UTC offset means
419    /// a later UTC instant; negating makes "later UTC" → larger value → Greater.
420    /// Floating (offset-naive) values use 0 as the tie-breaker.
421    fn end_components(&self) -> (i32, u8, u8, u8, u8, u8, u32, i16) {
422        match self {
423            IsmDate::Year(y) => (*y, 12, 31, 23, 59, 59, 999_999_999, 0),
424            IsmDate::YearMonth(y, m) => {
425                let d = days_in_month(*y, *m);
426                (*y, *m, d, 23, 59, 59, 999_999_999, 0)
427            }
428            IsmDate::Date(y, m, d) => (*y, *m, *d, 23, 59, 59, 999_999_999, 0),
429            IsmDate::DateHourMin {
430                year,
431                month,
432                day,
433                hour,
434                minute,
435                offset,
436            } => {
437                // Negate offset.minutes: a more-negative offset = later UTC instant = Greater.
438                let utc_tb = offset.map_or(0_i16, |o| -o.minutes);
439                (*year, *month, *day, *hour, *minute, 59, 999_999_999, utc_tb)
440            }
441            IsmDate::DateTime {
442                year,
443                month,
444                day,
445                hour,
446                minute,
447                second,
448                nanosecond,
449                offset,
450            } => {
451                let utc_tb = offset.map_or(0_i16, |o| -o.minutes);
452                (
453                    *year,
454                    *month,
455                    *day,
456                    *hour,
457                    *minute,
458                    *second,
459                    *nanosecond,
460                    utc_tb,
461                )
462            }
463        }
464    }
465}
466
467// ---------------------------------------------------------------------------
468// Display
469// ---------------------------------------------------------------------------
470
471impl fmt::Display for IsmDate {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        match self {
474            IsmDate::Year(y) => write!(f, "{:04}", y),
475            IsmDate::YearMonth(y, m) => write!(f, "{:04}-{:02}", y, m),
476            IsmDate::Date(y, m, d) => write!(f, "{:04}-{:02}-{:02}", y, m, d),
477            IsmDate::DateHourMin {
478                year,
479                month,
480                day,
481                hour,
482                minute,
483                offset,
484            } => {
485                write!(
486                    f,
487                    "{:04}-{:02}-{:02}T{:02}:{:02}",
488                    year, month, day, hour, minute
489                )?;
490                if let Some(o) = offset {
491                    write!(f, "{o}")?;
492                }
493                Ok(())
494            }
495            IsmDate::DateTime {
496                year,
497                month,
498                day,
499                hour,
500                minute,
501                second,
502                nanosecond,
503                offset,
504            } => {
505                write!(
506                    f,
507                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
508                    year, month, day, hour, minute, second
509                )?;
510                if *nanosecond > 0 {
511                    // Emit the shortest unambiguous fractional form.
512                    let s = format!("{:09}", nanosecond);
513                    let trimmed = s.trim_end_matches('0');
514                    write!(f, ".{trimmed}")?;
515                }
516                if let Some(o) = offset {
517                    write!(f, "{o}")?;
518                }
519                Ok(())
520            }
521        }
522    }
523}
524
525// ---------------------------------------------------------------------------
526// FromStr
527// ---------------------------------------------------------------------------
528
529/// Parse error for [`IsmDate`].
530#[derive(Debug, Clone, PartialEq, Eq)]
531pub struct ParseIsmDateError {
532    msg: &'static str,
533}
534
535impl fmt::Display for ParseIsmDateError {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        write!(f, "invalid ISM date: {}", self.msg)
538    }
539}
540
541impl std::error::Error for ParseIsmDateError {}
542
543impl ParseIsmDateError {
544    const fn new(msg: &'static str) -> Self {
545        Self { msg }
546    }
547}
548
549impl FromStr for IsmDate {
550    type Err = ParseIsmDateError;
551
552    fn from_str(s: &str) -> Result<Self, Self::Err> {
553        parse_ism_date(s)
554    }
555}
556
557impl FromStr for UtcOffset {
558    type Err = ParseIsmDateError;
559
560    /// Parse a standalone ISO 8601 UTC offset string.
561    ///
562    /// Accepted forms:
563    /// - `"Z"` → UTC (zero offset)
564    /// - `"+HH:MM"` → positive offset (e.g. `"+05:30"`)
565    /// - `"-HH:MM"` → negative offset (e.g. `"-05:00"`)
566    ///
567    /// Returns `Err` for any other form (e.g. `"EST"`, `"UTC"`, `"+0530"`).
568    fn from_str(s: &str) -> Result<Self, Self::Err> {
569        match s {
570            "Z" => Ok(UtcOffset::UTC),
571            _ if (s.starts_with('+') || s.starts_with('-')) && s.len() == 6 => {
572                let b = s.as_bytes();
573                // Require `:` separator at index 3 — rejects `+05-30` and `+0530`.
574                if b[3] != b':' {
575                    return Err(ParseIsmDateError::new(
576                        "UTC offset missing ':' separator (expected ±HH:MM)",
577                    ));
578                }
579                let sign: i8 = if s.starts_with('+') { 1 } else { -1 };
580                let oh =
581                    parse_2digits(&b[1..3]).ok_or(ParseIsmDateError::new("invalid offset hour"))?;
582                let om = parse_2digits(&b[4..6])
583                    .ok_or(ParseIsmDateError::new("invalid offset minute"))?;
584                UtcOffset::from_hhmm(sign, oh, om)
585                    .ok_or(ParseIsmDateError::new("UTC offset out of range"))
586            }
587            _ => Err(ParseIsmDateError::new(
588                "unrecognized UTC offset (expected Z or ±HH:MM)",
589            )),
590        }
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Internal parsing helpers
596// ---------------------------------------------------------------------------
597
598/// Parse `s` into an [`IsmDate`], accepting all XSD forms plus the CAPCO
599/// no-hyphen `YYYYMMDD` form.
600fn parse_ism_date(s: &str) -> Result<IsmDate, ParseIsmDateError> {
601    let bytes = s.as_bytes();
602    match bytes.len() {
603        // xsd:gYear — "YYYY"
604        4 if all_ascii_digits(bytes) => {
605            let y = parse_4digit_year(bytes)?;
606            Ok(IsmDate::Year(y))
607        }
608        // CAPCO no-hyphen date — "YYYYMMDD"
609        8 if all_ascii_digits(bytes) => {
610            let y = parse_4digit_year(&bytes[0..4])?;
611            let m = parse_2digits(&bytes[4..6])
612                .ok_or(ParseIsmDateError::new("invalid month digits"))?;
613            let d =
614                parse_2digits(&bytes[6..8]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
615            validate_date(y, m, d)?;
616            Ok(IsmDate::Date(y, m, d))
617        }
618        // xsd:gYearMonth — "YYYY-MM"
619        7 if bytes[4] == b'-' => {
620            let y = parse_4digit_year(&bytes[0..4])?;
621            let m = parse_2digits(&bytes[5..7])
622                .ok_or(ParseIsmDateError::new("invalid month digits"))?;
623            validate_year_month(y, m)?;
624            Ok(IsmDate::YearMonth(y, m))
625        }
626        // xsd:date — "YYYY-MM-DD"
627        10 if bytes[4] == b'-' && bytes[7] == b'-' => {
628            let y = parse_4digit_year(&bytes[0..4])?;
629            let m = parse_2digits(&bytes[5..7])
630                .ok_or(ParseIsmDateError::new("invalid month digits"))?;
631            let d =
632                parse_2digits(&bytes[8..10]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
633            validate_date(y, m, d)?;
634            Ok(IsmDate::Date(y, m, d))
635        }
636        // dateHourMinType or xsd:dateTime — "YYYY-MM-DDTHH:..."
637        _ if bytes.len() >= 16
638            && bytes[4] == b'-'
639            && bytes[7] == b'-'
640            && bytes[10] == b'T'
641            && bytes[13] == b':' =>
642        {
643            parse_datetime_or_hourmind(s)
644        }
645        _ => Err(ParseIsmDateError::new("unrecognized date format")),
646    }
647}
648
649/// Dispatch between `DateHourMin` and `DateTime` once the date portion has
650/// been identified.
651fn parse_datetime_or_hourmind(s: &str) -> Result<IsmDate, ParseIsmDateError> {
652    // All ISM date strings are pure ASCII. Reject multi-byte UTF-8 up front
653    // so every subsequent fixed byte-offset slice is panic-safe.
654    if !s.is_ascii() {
655        return Err(ParseIsmDateError::new(
656            "date string contains non-ASCII characters",
657        ));
658    }
659    let bytes = s.as_bytes();
660
661    let y = parse_4digit_year(&bytes[0..4])?;
662    let m = parse_2digits(&bytes[5..7]).ok_or(ParseIsmDateError::new("invalid month digits"))?;
663    let d = parse_2digits(&bytes[8..10]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
664    validate_date(y, m, d)?;
665
666    let h = parse_2digits(&bytes[11..13]).ok_or(ParseIsmDateError::new("invalid hour digits"))?;
667    let min =
668        parse_2digits(&bytes[14..16]).ok_or(ParseIsmDateError::new("invalid minute digits"))?;
669    if h > 23 {
670        return Err(ParseIsmDateError::new("hour out of range"));
671    }
672    if min > 59 {
673        return Err(ParseIsmDateError::new("minute out of range"));
674    }
675
676    let rest = &s[16..];
677
678    // dateHourMinType: pattern ends here (no `:SS` part), followed by
679    // optional offset or end-of-string.
680    if rest.is_empty() || rest.starts_with('Z') || rest.starts_with('+') || rest.starts_with('-') {
681        let offset = parse_offset(rest)?;
682        return Ok(IsmDate::DateHourMin {
683            year: y,
684            month: m,
685            day: d,
686            hour: h,
687            minute: min,
688            offset,
689        });
690    }
691
692    // xsd:dateTime: expect `:SS` next.
693    if !rest.starts_with(':') || rest.len() < 3 {
694        return Err(ParseIsmDateError::new("expected ':SS' in dateTime"));
695    }
696    let sec_bytes = &rest.as_bytes()[1..3];
697    let sec = parse_2digits(sec_bytes).ok_or(ParseIsmDateError::new("invalid second digits"))?;
698    if sec > 59 {
699        return Err(ParseIsmDateError::new("second out of range"));
700    }
701
702    let after_sec = &rest[3..];
703
704    // Optional fractional seconds: ".ddd..."
705    let (nanosecond, after_frac) = if let Some(frac_str) = after_sec.strip_prefix('.') {
706        // Collect consecutive digit characters.
707        let digit_end = frac_str.bytes().take_while(|b| b.is_ascii_digit()).count();
708        if digit_end == 0 {
709            return Err(ParseIsmDateError::new("empty fractional seconds"));
710        }
711        let frac_digits = &frac_str[..digit_end];
712        // Normalize to 9 digits (nanosecond precision).
713        let ns = parse_frac_as_nanoseconds(frac_digits)?;
714        (ns, &frac_str[digit_end..])
715    } else {
716        (0u32, after_sec)
717    };
718
719    let offset = parse_offset(after_frac)?;
720
721    Ok(IsmDate::DateTime {
722        year: y,
723        month: m,
724        day: d,
725        hour: h,
726        minute: min,
727        second: sec,
728        nanosecond,
729        offset,
730    })
731}
732
733/// Parse an optional timezone suffix (`""`, `"Z"`, `"+HH:MM"`, `"-HH:MM"`).
734/// Returns `Ok(None)` for an empty string (floating / offset-naive).
735fn parse_offset(s: &str) -> Result<Option<UtcOffset>, ParseIsmDateError> {
736    match s {
737        "" => Ok(None),
738        "Z" => Ok(Some(UtcOffset::UTC)),
739        _ if (s.starts_with('+') || s.starts_with('-')) && s.len() == 6 => {
740            let b = s.as_bytes();
741            // Require the `:` separator at index 3 explicitly so that inputs
742            // like `"+05-30"` (wrong separator) or `"+0530"` (missing one)
743            // are rejected rather than accidentally parsed.
744            if b[3] != b':' {
745                return Err(ParseIsmDateError::new(
746                    "UTC offset missing ':' separator (expected ±HH:MM)",
747                ));
748            }
749            let sign: i8 = if s.starts_with('+') { 1 } else { -1 };
750            let oh =
751                parse_2digits(&b[1..3]).ok_or(ParseIsmDateError::new("invalid offset hour"))?;
752            let om =
753                parse_2digits(&b[4..6]).ok_or(ParseIsmDateError::new("invalid offset minute"))?;
754            UtcOffset::from_hhmm(sign, oh, om)
755                .ok_or(ParseIsmDateError::new("UTC offset out of range"))
756                .map(Some)
757        }
758        _ => Err(ParseIsmDateError::new("unrecognized timezone suffix")),
759    }
760}
761
762/// Convert up to 9 fractional-second digits to nanoseconds.
763fn parse_frac_as_nanoseconds(frac: &str) -> Result<u32, ParseIsmDateError> {
764    if frac.len() > 9 {
765        return Err(ParseIsmDateError::new(
766            "fractional seconds: more than 9 digits",
767        ));
768    }
769    // Left-align: pad with trailing zeros to 9 digits.
770    let mut padded = [b'0'; 9];
771    padded[..frac.len()].copy_from_slice(frac.as_bytes());
772    let ns: u32 = std::str::from_utf8(&padded)
773        .ok()
774        .and_then(|s| s.parse().ok())
775        .ok_or(ParseIsmDateError::new("fractional seconds not numeric"))?;
776    Ok(ns)
777}
778
779// ---------------------------------------------------------------------------
780// Validation helpers (use jiff for date validity)
781// ---------------------------------------------------------------------------
782
783/// Validate a complete year/month/day triple using jiff's civil::Date.
784fn validate_date(year: i32, month: u8, day: u8) -> Result<(), ParseIsmDateError> {
785    let y = i16::try_from(year).map_err(|_| ParseIsmDateError::new("year out of i16 range"))?;
786    civil::Date::new(y, month as i8, day as i8)
787        .map_err(|_| ParseIsmDateError::new("invalid calendar date"))?;
788    Ok(())
789}
790
791/// Validate year/month (for `YearMonth` variant).
792fn validate_year_month(year: i32, month: u8) -> Result<(), ParseIsmDateError> {
793    if !(1..=12).contains(&month) {
794        return Err(ParseIsmDateError::new("month out of range 1–12"));
795    }
796    let _y = i16::try_from(year).map_err(|_| ParseIsmDateError::new("year out of i16 range"))?;
797    Ok(())
798}
799
800/// Number of days in the given month, using jiff for leap-year correctness.
801fn days_in_month(year: i32, month: u8) -> u8 {
802    let y = i16::try_from(year).unwrap_or(2000); // fallback for hypothetical overflow
803    civil::Date::new(y, month as i8, 1)
804        .map(|d| d.days_in_month() as u8)
805        .unwrap_or(30) // fallback — should not happen for valid inputs
806}
807
808// ---------------------------------------------------------------------------
809// Low-level byte-parsing utilities
810// ---------------------------------------------------------------------------
811
812/// Parse exactly 4 ASCII decimal digits as a signed year.
813fn parse_4digit_year(bytes: &[u8]) -> Result<i32, ParseIsmDateError> {
814    if bytes.len() != 4 || !all_ascii_digits(bytes) {
815        return Err(ParseIsmDateError::new("year must be exactly 4 digits"));
816    }
817    Ok(parse_digits_as_i32(bytes))
818}
819
820/// Parse exactly 2 ASCII decimal digits as a `u8`. Returns `None` if the
821/// bytes are not exactly two ASCII digits.
822#[inline]
823fn parse_2digits(bytes: &[u8]) -> Option<u8> {
824    if bytes.len() == 2 && bytes[0].is_ascii_digit() && bytes[1].is_ascii_digit() {
825        Some((bytes[0] - b'0') * 10 + (bytes[1] - b'0'))
826    } else {
827        None
828    }
829}
830
831/// Convert a slice of ASCII digits to `i32` (no overflow check — callers
832/// limit input length).
833#[inline]
834fn parse_digits_as_i32(bytes: &[u8]) -> i32 {
835    bytes
836        .iter()
837        .fold(0i32, |acc, b| acc * 10 + (*b - b'0') as i32)
838}
839
840/// Returns `true` iff every byte in `bytes` is an ASCII decimal digit.
841#[inline]
842fn all_ascii_digits(bytes: &[u8]) -> bool {
843    bytes.iter().all(|b| b.is_ascii_digit())
844}
845
846// ---------------------------------------------------------------------------
847// ApproxQualifier
848// ---------------------------------------------------------------------------
849
850/// Approximation qualifier from `DateApproximationVocabType`.
851///
852/// Paired with an [`IsmDate`] in [`ApproxIsmDate`] to express constructions
853/// like "circa 1995" or "early 2003". The qualifier is informational and does
854/// not affect [`IsmDate::contains`] or [`IsmDate::end_cmp`].
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
856pub enum ApproxQualifier {
857    /// `"1st qtr"` — first quarter of the year.
858    FirstQtr,
859    /// `"2nd qtr"` — second quarter.
860    SecondQtr,
861    /// `"3rd qtr"` — third quarter.
862    ThirdQtr,
863    /// `"4th qtr"` — fourth quarter.
864    FourthQtr,
865    /// `"circa"` — approximately.
866    Circa,
867    /// `"early"` — early portion of the period.
868    Early,
869    /// `"mid"` — middle portion of the period.
870    Mid,
871    /// `"late"` — late portion of the period.
872    Late,
873}
874
875impl fmt::Display for ApproxQualifier {
876    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
877        let s = match self {
878            ApproxQualifier::FirstQtr => "1st qtr",
879            ApproxQualifier::SecondQtr => "2nd qtr",
880            ApproxQualifier::ThirdQtr => "3rd qtr",
881            ApproxQualifier::FourthQtr => "4th qtr",
882            ApproxQualifier::Circa => "circa",
883            ApproxQualifier::Early => "early",
884            ApproxQualifier::Mid => "mid",
885            ApproxQualifier::Late => "late",
886        };
887        f.write_str(s)
888    }
889}
890
891/// Parse error for [`ApproxQualifier`].
892#[derive(Debug, Clone, PartialEq, Eq)]
893pub struct ParseApproxQualifierError;
894
895impl fmt::Display for ParseApproxQualifierError {
896    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
897        write!(f, "invalid approx qualifier")
898    }
899}
900
901impl std::error::Error for ParseApproxQualifierError {}
902
903impl FromStr for ApproxQualifier {
904    type Err = ParseApproxQualifierError;
905
906    fn from_str(s: &str) -> Result<Self, Self::Err> {
907        match s {
908            "1st qtr" => Ok(ApproxQualifier::FirstQtr),
909            "2nd qtr" => Ok(ApproxQualifier::SecondQtr),
910            "3rd qtr" => Ok(ApproxQualifier::ThirdQtr),
911            "4th qtr" => Ok(ApproxQualifier::FourthQtr),
912            "circa" => Ok(ApproxQualifier::Circa),
913            "early" => Ok(ApproxQualifier::Early),
914            "mid" => Ok(ApproxQualifier::Mid),
915            "late" => Ok(ApproxQualifier::Late),
916            _ => Err(ParseApproxQualifierError),
917        }
918    }
919}
920
921// ---------------------------------------------------------------------------
922// ApproxIsmDate
923// ---------------------------------------------------------------------------
924
925/// An [`IsmDate`] paired with an optional [`ApproxQualifier`].
926///
927/// Models the `DateApproximationVocabType` companion axis. Example: "circa
928/// 1995" is `ApproxIsmDate { date: IsmDate::Year(1995), qualifier:
929/// Some(ApproxQualifier::Circa) }`.
930///
931/// The qualifier is preserved for round-trip and display but does not affect
932/// span containment or ordering semantics.
933#[derive(Debug, Clone, PartialEq, Eq, Hash)]
934pub struct ApproxIsmDate {
935    pub date: IsmDate,
936    pub qualifier: Option<ApproxQualifier>,
937}
938
939impl fmt::Display for ApproxIsmDate {
940    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
941        if let Some(q) = self.qualifier {
942            write!(f, "{} {}", q, self.date)
943        } else {
944            write!(f, "{}", self.date)
945        }
946    }
947}
948
949// ---------------------------------------------------------------------------
950// Tests
951// ---------------------------------------------------------------------------
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use std::str::FromStr;
957
958    // -----------------------------------------------------------------------
959    // Round-trip: Display → FromStr
960    // -----------------------------------------------------------------------
961
962    fn round_trip(s: &str) -> bool {
963        IsmDate::from_str(s)
964            .map(|d| d.to_string() == s)
965            .unwrap_or(false)
966    }
967
968    #[test]
969    fn round_trip_year() {
970        assert!(round_trip("2003"));
971        assert!(round_trip("1900"));
972        assert!(round_trip("9999"));
973    }
974
975    #[test]
976    fn round_trip_year_month() {
977        assert!(round_trip("2003-04"));
978        assert!(round_trip("2003-12"));
979        assert!(round_trip("2003-01"));
980    }
981
982    #[test]
983    fn round_trip_date() {
984        assert!(round_trip("2003-04-15"));
985        assert!(round_trip("2000-02-29")); // leap year
986    }
987
988    #[test]
989    fn round_trip_date_hour_min_utc() {
990        assert!(round_trip("2003-04-15T14:30Z"));
991    }
992
993    #[test]
994    fn round_trip_date_hour_min_offset() {
995        assert!(round_trip("2003-04-15T14:30-05:00"));
996        assert!(round_trip("2003-04-15T14:30+05:30"));
997    }
998
999    #[test]
1000    fn round_trip_date_hour_min_floating() {
1001        assert!(round_trip("2003-04-15T14:30"));
1002    }
1003
1004    #[test]
1005    fn round_trip_datetime_utc() {
1006        assert!(round_trip("2003-04-15T14:30:00Z"));
1007    }
1008
1009    #[test]
1010    fn round_trip_datetime_with_millis() {
1011        assert!(round_trip("2003-04-15T14:30:00.123Z"));
1012    }
1013
1014    #[test]
1015    fn round_trip_datetime_with_micros() {
1016        assert!(round_trip("2003-04-15T14:30:00.123456Z"));
1017    }
1018
1019    #[test]
1020    fn round_trip_datetime_floating() {
1021        assert!(round_trip("2003-04-15T14:30:00"));
1022    }
1023
1024    // -----------------------------------------------------------------------
1025    // CAPCO no-hyphen YYYYMMDD input
1026    // -----------------------------------------------------------------------
1027
1028    #[test]
1029    fn capco_yyyymmdd_parses_to_date() {
1030        let d = IsmDate::from_str("20030415").unwrap();
1031        assert_eq!(d, IsmDate::Date(2003, 4, 15));
1032    }
1033
1034    #[test]
1035    fn capco_year_only_parses_to_year() {
1036        let d = IsmDate::from_str("2035").unwrap();
1037        assert_eq!(d, IsmDate::Year(2035));
1038    }
1039
1040    #[test]
1041    fn capco_display_uses_iso_form() {
1042        let d = IsmDate::from_str("20030415").unwrap();
1043        assert_eq!(d.to_string(), "2003-04-15");
1044    }
1045
1046    // -----------------------------------------------------------------------
1047    // Validation rejects invalid dates
1048    // -----------------------------------------------------------------------
1049
1050    #[test]
1051    fn rejects_invalid_month() {
1052        assert!(IsmDate::from_str("2003-13").is_err());
1053        assert!(IsmDate::from_str("2003-00").is_err());
1054    }
1055
1056    #[test]
1057    fn rejects_invalid_day() {
1058        assert!(IsmDate::from_str("2003-02-29").is_err()); // 2003 not leap year
1059        assert!(IsmDate::from_str("2003-04-31").is_err()); // April has 30 days
1060    }
1061
1062    #[test]
1063    fn accepts_leap_day_in_leap_year() {
1064        assert!(IsmDate::from_str("2000-02-29").is_ok()); // 2000 is leap
1065        assert!(IsmDate::from_str("2004-02-29").is_ok()); // 2004 is leap
1066    }
1067
1068    // -----------------------------------------------------------------------
1069    // IsmDate::contains
1070    // -----------------------------------------------------------------------
1071
1072    #[test]
1073    fn year_contains_same_year() {
1074        let y = IsmDate::Year(2003);
1075        assert!(y.contains(&IsmDate::Year(2003)));
1076    }
1077
1078    #[test]
1079    fn year_contains_year_month() {
1080        let y = IsmDate::Year(2003);
1081        assert!(y.contains(&IsmDate::YearMonth(2003, 4)));
1082        assert!(!y.contains(&IsmDate::YearMonth(2004, 1)));
1083    }
1084
1085    #[test]
1086    fn year_contains_date() {
1087        let y = IsmDate::Year(2003);
1088        assert!(y.contains(&IsmDate::Date(2003, 12, 31)));
1089        assert!(!y.contains(&IsmDate::Date(2004, 1, 1)));
1090    }
1091
1092    #[test]
1093    fn year_month_does_not_contain_year() {
1094        let ym = IsmDate::YearMonth(2003, 4);
1095        assert!(!ym.contains(&IsmDate::Year(2003)));
1096    }
1097
1098    #[test]
1099    fn year_month_contains_same_month_date() {
1100        let ym = IsmDate::YearMonth(2003, 4);
1101        assert!(ym.contains(&IsmDate::Date(2003, 4, 1)));
1102        assert!(ym.contains(&IsmDate::Date(2003, 4, 30)));
1103        assert!(!ym.contains(&IsmDate::Date(2003, 5, 1)));
1104    }
1105
1106    #[test]
1107    fn date_does_not_contain_coarser() {
1108        let d = IsmDate::Date(2003, 4, 15);
1109        assert!(!d.contains(&IsmDate::Year(2003)));
1110        assert!(!d.contains(&IsmDate::YearMonth(2003, 4)));
1111    }
1112
1113    #[test]
1114    fn date_contains_self() {
1115        let d = IsmDate::Date(2003, 4, 15);
1116        assert!(d.contains(&IsmDate::Date(2003, 4, 15)));
1117    }
1118
1119    #[test]
1120    fn date_contains_hour_min_on_same_day() {
1121        let d = IsmDate::Date(2003, 4, 15);
1122        assert!(d.contains(&IsmDate::DateHourMin {
1123            year: 2003,
1124            month: 4,
1125            day: 15,
1126            hour: 14,
1127            minute: 30,
1128            offset: None,
1129        }));
1130    }
1131
1132    #[test]
1133    fn date_does_not_contain_hour_min_different_day() {
1134        let d = IsmDate::Date(2003, 4, 15);
1135        assert!(!d.contains(&IsmDate::DateHourMin {
1136            year: 2003,
1137            month: 4,
1138            day: 16,
1139            hour: 0,
1140            minute: 0,
1141            offset: None,
1142        }));
1143    }
1144
1145    // -----------------------------------------------------------------------
1146    // end_cmp / to_maxdate_str
1147    // -----------------------------------------------------------------------
1148
1149    #[test]
1150    fn year_end_cmp_is_greater_than_mid_year_date() {
1151        let year = IsmDate::Year(2003);
1152        let mid = IsmDate::Date(2003, 6, 15);
1153        // Year(2003) ends on Dec 31; Date(2003,6,15) ends on Jun 15.
1154        assert_eq!(year.end_cmp(&mid), Ordering::Greater);
1155    }
1156
1157    #[test]
1158    fn year_month_end_cmp_greater_than_early_date_in_month() {
1159        let ym = IsmDate::YearMonth(2003, 4); // ends Apr 30
1160        let d = IsmDate::Date(2003, 4, 1); // ends Apr 1
1161        assert_eq!(ym.end_cmp(&d), Ordering::Greater);
1162    }
1163
1164    #[test]
1165    fn date_end_cmp_greater_than_date_hour_min_same_day() {
1166        // Date(y,m,d) end = (y,m,d, 23,59,59,999_999_999).
1167        // DateHourMin { hour:22, minute:30 } end = (y,m,d, 22,30,59,999_999_999).
1168        // The full day outlasts even a very late DateHourMin.
1169        let day = IsmDate::Date(2003, 4, 15);
1170        let t = IsmDate::DateHourMin {
1171            year: 2003,
1172            month: 4,
1173            day: 15,
1174            hour: 22,
1175            minute: 30,
1176            offset: None,
1177        };
1178        assert_eq!(day.end_cmp(&t), Ordering::Greater);
1179    }
1180
1181    #[test]
1182    fn date_hour_min_end_cmp_later_time_is_greater() {
1183        let earlier = IsmDate::DateHourMin {
1184            year: 2003,
1185            month: 4,
1186            day: 15,
1187            hour: 10,
1188            minute: 0,
1189            offset: None,
1190        };
1191        let later = IsmDate::DateHourMin {
1192            year: 2003,
1193            month: 4,
1194            day: 15,
1195            hour: 14,
1196            minute: 30,
1197            offset: None,
1198        };
1199        assert_eq!(later.end_cmp(&earlier), Ordering::Greater);
1200        assert_eq!(earlier.end_cmp(&later), Ordering::Less);
1201    }
1202
1203    #[test]
1204    fn date_hour_min_end_cmp_equal_times_is_equal() {
1205        let a = IsmDate::DateHourMin {
1206            year: 2003,
1207            month: 4,
1208            day: 15,
1209            hour: 14,
1210            minute: 30,
1211            offset: None,
1212        };
1213        let b = a.clone();
1214        assert_eq!(a.end_cmp(&b), Ordering::Equal);
1215    }
1216
1217    #[test]
1218    fn date_hour_min_end_cmp_same_civil_negative_offset_is_greater() {
1219        // 10:30-05:00 = 15:30 UTC > 10:30Z = 10:30 UTC
1220        let utc = IsmDate::DateHourMin {
1221            year: 2003,
1222            month: 4,
1223            day: 15,
1224            hour: 10,
1225            minute: 30,
1226            offset: Some(UtcOffset::UTC),
1227        };
1228        let eastern = IsmDate::DateHourMin {
1229            year: 2003,
1230            month: 4,
1231            day: 15,
1232            hour: 10,
1233            minute: 30,
1234            offset: Some(UtcOffset::from_hhmm(-1, 5, 0).unwrap()), // -05:00
1235        };
1236        // Eastern is later in UTC, so it should be Greater.
1237        assert_eq!(eastern.end_cmp(&utc), Ordering::Greater);
1238        assert_eq!(utc.end_cmp(&eastern), Ordering::Less);
1239    }
1240
1241    #[test]
1242    fn date_hour_min_end_cmp_same_civil_positive_offset_is_less() {
1243        // 10:30+05:30 = 05:00 UTC < 10:30Z = 10:30 UTC
1244        let utc = IsmDate::DateHourMin {
1245            year: 2003,
1246            month: 4,
1247            day: 15,
1248            hour: 10,
1249            minute: 30,
1250            offset: Some(UtcOffset::UTC),
1251        };
1252        let india = IsmDate::DateHourMin {
1253            year: 2003,
1254            month: 4,
1255            day: 15,
1256            hour: 10,
1257            minute: 30,
1258            offset: Some(UtcOffset::from_hhmm(1, 5, 30).unwrap()), // +05:30
1259        };
1260        // India is earlier in UTC, so it should be Less.
1261        assert_eq!(india.end_cmp(&utc), Ordering::Less);
1262        assert_eq!(utc.end_cmp(&india), Ordering::Greater);
1263    }
1264
1265    #[test]
1266    fn to_maxdate_str_year() {
1267        assert_eq!(&*IsmDate::Year(2003).to_maxdate_str(), "20031231");
1268    }
1269
1270    #[test]
1271    fn to_maxdate_str_year_month_april() {
1272        assert_eq!(&*IsmDate::YearMonth(2003, 4).to_maxdate_str(), "20030430");
1273    }
1274
1275    #[test]
1276    fn to_maxdate_str_year_month_february_non_leap() {
1277        assert_eq!(&*IsmDate::YearMonth(2003, 2).to_maxdate_str(), "20030228");
1278    }
1279
1280    #[test]
1281    fn to_maxdate_str_year_month_february_leap() {
1282        assert_eq!(&*IsmDate::YearMonth(2000, 2).to_maxdate_str(), "20000229");
1283    }
1284
1285    #[test]
1286    fn to_maxdate_str_date() {
1287        assert_eq!(&*IsmDate::Date(2003, 4, 15).to_maxdate_str(), "20030415");
1288    }
1289
1290    // -----------------------------------------------------------------------
1291    // ApproxQualifier round-trip
1292    // -----------------------------------------------------------------------
1293
1294    #[test]
1295    fn approx_qualifier_round_trip() {
1296        for q in [
1297            ApproxQualifier::FirstQtr,
1298            ApproxQualifier::SecondQtr,
1299            ApproxQualifier::ThirdQtr,
1300            ApproxQualifier::FourthQtr,
1301            ApproxQualifier::Circa,
1302            ApproxQualifier::Early,
1303            ApproxQualifier::Mid,
1304            ApproxQualifier::Late,
1305        ] {
1306            let s = q.to_string();
1307            assert_eq!(ApproxQualifier::from_str(&s).unwrap(), q);
1308        }
1309    }
1310
1311    // -----------------------------------------------------------------------
1312    // UtcOffset
1313    // -----------------------------------------------------------------------
1314
1315    #[test]
1316    fn utc_offset_display_utc() {
1317        assert_eq!(UtcOffset::UTC.to_string(), "Z");
1318    }
1319
1320    #[test]
1321    fn utc_offset_display_positive() {
1322        let o = UtcOffset::from_hhmm(1, 5, 30).unwrap();
1323        assert_eq!(o.to_string(), "+05:30");
1324    }
1325
1326    #[test]
1327    fn utc_offset_display_negative() {
1328        let o = UtcOffset::from_hhmm(-1, 5, 0).unwrap();
1329        assert_eq!(o.to_string(), "-05:00");
1330    }
1331
1332    #[test]
1333    fn utc_offset_rejects_invalid() {
1334        assert!(UtcOffset::from_hhmm(1, 24, 0).is_none()); // hours > 23
1335        assert!(UtcOffset::from_hhmm(1, 0, 60).is_none()); // minutes > 59
1336    }
1337
1338    #[test]
1339    fn utc_offset_from_str_z_is_utc() {
1340        assert_eq!("Z".parse::<UtcOffset>().unwrap(), UtcOffset::UTC);
1341    }
1342
1343    #[test]
1344    fn utc_offset_from_str_positive() {
1345        let o = "+05:30".parse::<UtcOffset>().unwrap();
1346        assert_eq!(o, UtcOffset::from_hhmm(1, 5, 30).unwrap());
1347    }
1348
1349    #[test]
1350    fn utc_offset_from_str_negative() {
1351        let o = "-05:00".parse::<UtcOffset>().unwrap();
1352        assert_eq!(o, UtcOffset::from_hhmm(-1, 5, 0).unwrap());
1353    }
1354
1355    #[test]
1356    fn utc_offset_from_str_round_trip() {
1357        // "+00:00" canonicalizes to "Z" so it is excluded from this round-trip.
1358        for s in ["Z", "+05:30", "-05:00", "+23:59", "-23:59"] {
1359            let parsed: UtcOffset = s.parse().unwrap();
1360            assert_eq!(parsed.to_string(), s, "round-trip failed for {s:?}");
1361        }
1362        // Both "+00:00" and "Z" parse to UTC; canonical display is "Z".
1363        let zero: UtcOffset = "+00:00".parse().unwrap();
1364        assert_eq!(zero, UtcOffset::UTC);
1365        assert_eq!(zero.to_string(), "Z");
1366    }
1367
1368    #[test]
1369    fn utc_offset_from_str_rejects_invalid() {
1370        for bad in [
1371            "EST", "UTC", "utc", "+0530", "+05-30", "05:30", "", "+24:00",
1372        ] {
1373            assert!(bad.parse::<UtcOffset>().is_err(), "should reject {bad:?}");
1374        }
1375    }
1376
1377    #[test]
1378    fn parse_offset_rejects_wrong_separator() {
1379        // `+05-30` has `-` instead of `:` at index 3 — must be rejected.
1380        let err = IsmDate::from_str("2003-04-15T10:30+05-30");
1381        assert!(
1382            err.is_err(),
1383            "offset with wrong separator should be Err, got {err:?}"
1384        );
1385        // `+0530` (missing separator entirely) is 5 bytes, not 6 — also rejected.
1386        let err2 = IsmDate::from_str("2003-04-15T10:30+0530");
1387        assert!(
1388            err2.is_err(),
1389            "offset without separator should be Err, got {err2:?}"
1390        );
1391    }
1392
1393    #[test]
1394    fn parse_datetime_rejects_non_ascii() {
1395        // Multi-byte UTF-8 must not cause a panic in the byte-offset slicer.
1396        let result = IsmDate::from_str("2003-04-15T10:30\u{00E9}");
1397        assert!(result.is_err(), "non-ASCII should be Err, got {result:?}");
1398    }
1399
1400    // -----------------------------------------------------------------------
1401    // UtcOffset additional coverage
1402    // -----------------------------------------------------------------------
1403
1404    #[test]
1405    fn utc_offset_from_hhmm_invalid_sign_zero() {
1406        assert!(
1407            UtcOffset::from_hhmm(0, 5, 0).is_none(),
1408            "sign=0 must be rejected"
1409        );
1410    }
1411
1412    #[test]
1413    fn utc_offset_from_hhmm_invalid_sign_two() {
1414        assert!(
1415            UtcOffset::from_hhmm(2, 5, 0).is_none(),
1416            "sign=2 must be rejected"
1417        );
1418    }
1419
1420    #[test]
1421    fn utc_offset_from_hhmm_invalid_sign_minus_two() {
1422        assert!(
1423            UtcOffset::from_hhmm(-2, 5, 0).is_none(),
1424            "sign=-2 must be rejected"
1425        );
1426    }
1427
1428    #[test]
1429    fn utc_offset_from_hhmm_max_valid_boundary() {
1430        // ±23:59 is the maximum representable offset (1439 minutes).
1431        let pos = UtcOffset::from_hhmm(1, 23, 59).unwrap();
1432        assert_eq!(pos.minutes, 23 * 60 + 59);
1433        let neg = UtcOffset::from_hhmm(-1, 23, 59).unwrap();
1434        assert_eq!(neg.minutes, -(23 * 60 + 59));
1435    }
1436
1437    #[test]
1438    fn utc_offset_from_hhmm_rejects_hours_24() {
1439        assert!(
1440            UtcOffset::from_hhmm(1, 24, 0).is_none(),
1441            "hours=24 must be rejected"
1442        );
1443    }
1444
1445    #[test]
1446    fn utc_offset_from_hhmm_rejects_minutes_60() {
1447        assert!(
1448            UtcOffset::from_hhmm(1, 0, 60).is_none(),
1449            "minutes=60 must be rejected"
1450        );
1451    }
1452
1453    #[test]
1454    fn utc_offset_to_seconds_utc_is_zero() {
1455        assert_eq!(UtcOffset::UTC.to_seconds(), 0);
1456    }
1457
1458    #[test]
1459    fn utc_offset_to_seconds_positive() {
1460        // +05:30 = 330 minutes = 19800 seconds
1461        let o = UtcOffset::from_hhmm(1, 5, 30).unwrap();
1462        assert_eq!(o.to_seconds(), 5 * 3600 + 30 * 60);
1463    }
1464
1465    #[test]
1466    fn utc_offset_to_seconds_negative() {
1467        // -05:00 = -300 minutes = -18000 seconds
1468        let o = UtcOffset::from_hhmm(-1, 5, 0).unwrap();
1469        assert_eq!(o.to_seconds(), -5 * 3600);
1470    }
1471
1472    #[test]
1473    fn utc_offset_from_hhmm_zero_positive_sign() {
1474        // Both sign=1 and sign=-1 produce UTC for 0 hours / 0 minutes.
1475        let pos = UtcOffset::from_hhmm(1, 0, 0).unwrap();
1476        let neg = UtcOffset::from_hhmm(-1, 0, 0).unwrap();
1477        assert_eq!(pos, UtcOffset::UTC);
1478        assert_eq!(neg, UtcOffset::UTC);
1479    }
1480
1481    // -----------------------------------------------------------------------
1482    // IsmDate component accessors
1483    // -----------------------------------------------------------------------
1484
1485    #[test]
1486    fn year_accessor_all_variants() {
1487        assert_eq!(IsmDate::Year(2003).year(), 2003);
1488        assert_eq!(IsmDate::YearMonth(2003, 4).year(), 2003);
1489        assert_eq!(IsmDate::Date(2003, 4, 15).year(), 2003);
1490        assert_eq!(
1491            IsmDate::DateHourMin {
1492                year: 2003,
1493                month: 4,
1494                day: 15,
1495                hour: 10,
1496                minute: 30,
1497                offset: None,
1498            }
1499            .year(),
1500            2003
1501        );
1502        assert_eq!(
1503            IsmDate::DateTime {
1504                year: 2003,
1505                month: 4,
1506                day: 15,
1507                hour: 10,
1508                minute: 30,
1509                second: 0,
1510                nanosecond: 0,
1511                offset: None,
1512            }
1513            .year(),
1514            2003
1515        );
1516    }
1517
1518    #[test]
1519    fn month_accessor_all_variants() {
1520        assert_eq!(IsmDate::Year(2003).month(), None);
1521        assert_eq!(IsmDate::YearMonth(2003, 4).month(), Some(4));
1522        assert_eq!(IsmDate::Date(2003, 4, 15).month(), Some(4));
1523        assert_eq!(
1524            IsmDate::DateHourMin {
1525                year: 2003,
1526                month: 4,
1527                day: 15,
1528                hour: 10,
1529                minute: 30,
1530                offset: None,
1531            }
1532            .month(),
1533            Some(4)
1534        );
1535        assert_eq!(
1536            IsmDate::DateTime {
1537                year: 2003,
1538                month: 4,
1539                day: 15,
1540                hour: 10,
1541                minute: 30,
1542                second: 0,
1543                nanosecond: 0,
1544                offset: None,
1545            }
1546            .month(),
1547            Some(4)
1548        );
1549    }
1550
1551    #[test]
1552    fn day_accessor_all_variants() {
1553        assert_eq!(IsmDate::Year(2003).day(), None);
1554        assert_eq!(IsmDate::YearMonth(2003, 4).day(), None);
1555        assert_eq!(IsmDate::Date(2003, 4, 15).day(), Some(15));
1556        assert_eq!(
1557            IsmDate::DateHourMin {
1558                year: 2003,
1559                month: 4,
1560                day: 15,
1561                hour: 10,
1562                minute: 30,
1563                offset: None,
1564            }
1565            .day(),
1566            Some(15)
1567        );
1568        assert_eq!(
1569            IsmDate::DateTime {
1570                year: 2003,
1571                month: 4,
1572                day: 15,
1573                hour: 10,
1574                minute: 30,
1575                second: 0,
1576                nanosecond: 0,
1577                offset: None,
1578            }
1579            .day(),
1580            Some(15)
1581        );
1582    }
1583
1584    // -----------------------------------------------------------------------
1585    // IsmDate::contains — DateHourMin and DateTime cases
1586    // -----------------------------------------------------------------------
1587
1588    #[test]
1589    fn date_hour_min_contains_itself() {
1590        let t = IsmDate::DateHourMin {
1591            year: 2003,
1592            month: 4,
1593            day: 15,
1594            hour: 14,
1595            minute: 30,
1596            offset: Some(UtcOffset::UTC),
1597        };
1598        assert!(t.contains(&t.clone()));
1599    }
1600
1601    #[test]
1602    fn date_hour_min_does_not_contain_coarser() {
1603        let t = IsmDate::DateHourMin {
1604            year: 2003,
1605            month: 4,
1606            day: 15,
1607            hour: 14,
1608            minute: 30,
1609            offset: None,
1610        };
1611        assert!(!t.contains(&IsmDate::Year(2003)));
1612        assert!(!t.contains(&IsmDate::YearMonth(2003, 4)));
1613        assert!(!t.contains(&IsmDate::Date(2003, 4, 15)));
1614    }
1615
1616    #[test]
1617    fn date_hour_min_contains_datetime_same_minute() {
1618        let dhm = IsmDate::DateHourMin {
1619            year: 2003,
1620            month: 4,
1621            day: 15,
1622            hour: 14,
1623            minute: 30,
1624            offset: None,
1625        };
1626        // DateTime within the same HH:MM must be contained.
1627        let dt = IsmDate::DateTime {
1628            year: 2003,
1629            month: 4,
1630            day: 15,
1631            hour: 14,
1632            minute: 30,
1633            second: 45,
1634            nanosecond: 0,
1635            offset: None,
1636        };
1637        assert!(dhm.contains(&dt));
1638    }
1639
1640    #[test]
1641    fn date_hour_min_does_not_contain_datetime_different_minute() {
1642        let dhm = IsmDate::DateHourMin {
1643            year: 2003,
1644            month: 4,
1645            day: 15,
1646            hour: 14,
1647            minute: 30,
1648            offset: None,
1649        };
1650        let dt = IsmDate::DateTime {
1651            year: 2003,
1652            month: 4,
1653            day: 15,
1654            hour: 14,
1655            minute: 31,
1656            second: 0,
1657            nanosecond: 0,
1658            offset: None,
1659        };
1660        assert!(!dhm.contains(&dt));
1661    }
1662
1663    #[test]
1664    fn date_hour_min_does_not_contain_datetime_different_offset() {
1665        // Offsets are compared in their represented form (no UTC normalization).
1666        let dhm = IsmDate::DateHourMin {
1667            year: 2003,
1668            month: 4,
1669            day: 15,
1670            hour: 14,
1671            minute: 30,
1672            offset: Some(UtcOffset::UTC),
1673        };
1674        let dt = IsmDate::DateTime {
1675            year: 2003,
1676            month: 4,
1677            day: 15,
1678            hour: 14,
1679            minute: 30,
1680            second: 0,
1681            nanosecond: 0,
1682            offset: Some(UtcOffset::from_hhmm(-1, 5, 0).unwrap()),
1683        };
1684        assert!(!dhm.contains(&dt));
1685    }
1686
1687    #[test]
1688    fn datetime_contains_itself() {
1689        let dt = IsmDate::DateTime {
1690            year: 2003,
1691            month: 4,
1692            day: 15,
1693            hour: 14,
1694            minute: 30,
1695            second: 45,
1696            nanosecond: 123_456_789,
1697            offset: Some(UtcOffset::UTC),
1698        };
1699        assert!(dt.contains(&dt.clone()));
1700    }
1701
1702    #[test]
1703    fn datetime_does_not_contain_coarser() {
1704        let dt = IsmDate::DateTime {
1705            year: 2003,
1706            month: 4,
1707            day: 15,
1708            hour: 14,
1709            minute: 30,
1710            second: 45,
1711            nanosecond: 0,
1712            offset: None,
1713        };
1714        assert!(!dt.contains(&IsmDate::Year(2003)));
1715        assert!(!dt.contains(&IsmDate::YearMonth(2003, 4)));
1716        assert!(!dt.contains(&IsmDate::Date(2003, 4, 15)));
1717    }
1718
1719    #[test]
1720    fn datetime_does_not_contain_datehourmin() {
1721        let dt = IsmDate::DateTime {
1722            year: 2003,
1723            month: 4,
1724            day: 15,
1725            hour: 14,
1726            minute: 30,
1727            second: 45,
1728            nanosecond: 0,
1729            offset: None,
1730        };
1731        let dhm = IsmDate::DateHourMin {
1732            year: 2003,
1733            month: 4,
1734            day: 15,
1735            hour: 14,
1736            minute: 30,
1737            offset: None,
1738        };
1739        assert!(!dt.contains(&dhm));
1740    }
1741
1742    #[test]
1743    fn year_contains_datehourmin_same_year() {
1744        let y = IsmDate::Year(2003);
1745        let t = IsmDate::DateHourMin {
1746            year: 2003,
1747            month: 6,
1748            day: 15,
1749            hour: 10,
1750            minute: 0,
1751            offset: None,
1752        };
1753        assert!(y.contains(&t));
1754    }
1755
1756    #[test]
1757    fn year_contains_datetime_same_year() {
1758        let y = IsmDate::Year(2003);
1759        let dt = IsmDate::DateTime {
1760            year: 2003,
1761            month: 12,
1762            day: 31,
1763            hour: 23,
1764            minute: 59,
1765            second: 59,
1766            nanosecond: 0,
1767            offset: None,
1768        };
1769        assert!(y.contains(&dt));
1770    }
1771
1772    #[test]
1773    fn year_does_not_contain_datehourmin_different_year() {
1774        let y = IsmDate::Year(2003);
1775        let t = IsmDate::DateHourMin {
1776            year: 2004,
1777            month: 1,
1778            day: 1,
1779            hour: 0,
1780            minute: 0,
1781            offset: None,
1782        };
1783        assert!(!y.contains(&t));
1784    }
1785
1786    #[test]
1787    fn year_month_contains_datehourmin_same_month() {
1788        let ym = IsmDate::YearMonth(2003, 4);
1789        let t = IsmDate::DateHourMin {
1790            year: 2003,
1791            month: 4,
1792            day: 15,
1793            hour: 10,
1794            minute: 0,
1795            offset: None,
1796        };
1797        assert!(ym.contains(&t));
1798    }
1799
1800    #[test]
1801    fn year_month_does_not_contain_datehourmin_different_month() {
1802        let ym = IsmDate::YearMonth(2003, 4);
1803        let t = IsmDate::DateHourMin {
1804            year: 2003,
1805            month: 5,
1806            day: 1,
1807            hour: 0,
1808            minute: 0,
1809            offset: None,
1810        };
1811        assert!(!ym.contains(&t));
1812    }
1813
1814    #[test]
1815    fn date_contains_datetime_same_day() {
1816        let d = IsmDate::Date(2003, 4, 15);
1817        let dt = IsmDate::DateTime {
1818            year: 2003,
1819            month: 4,
1820            day: 15,
1821            hour: 23,
1822            minute: 59,
1823            second: 59,
1824            nanosecond: 999_999_999,
1825            offset: None,
1826        };
1827        assert!(d.contains(&dt));
1828    }
1829
1830    #[test]
1831    fn date_does_not_contain_datetime_different_day() {
1832        let d = IsmDate::Date(2003, 4, 15);
1833        let dt = IsmDate::DateTime {
1834            year: 2003,
1835            month: 4,
1836            day: 16,
1837            hour: 0,
1838            minute: 0,
1839            second: 0,
1840            nanosecond: 0,
1841            offset: None,
1842        };
1843        assert!(!d.contains(&dt));
1844    }
1845
1846    // -----------------------------------------------------------------------
1847    // IsmDate::end_cmp — additional cross-tier and same-tier cases
1848    // -----------------------------------------------------------------------
1849
1850    #[test]
1851    fn year_end_cmp_same_year_is_equal() {
1852        assert_eq!(
1853            IsmDate::Year(2003).end_cmp(&IsmDate::Year(2003)),
1854            Ordering::Equal
1855        );
1856    }
1857
1858    #[test]
1859    fn year_end_cmp_different_years() {
1860        assert_eq!(
1861            IsmDate::Year(2004).end_cmp(&IsmDate::Year(2003)),
1862            Ordering::Greater
1863        );
1864        assert_eq!(
1865            IsmDate::Year(2003).end_cmp(&IsmDate::Year(2004)),
1866            Ordering::Less
1867        );
1868    }
1869
1870    #[test]
1871    fn year_month_end_cmp_same_month_is_equal() {
1872        assert_eq!(
1873            IsmDate::YearMonth(2003, 4).end_cmp(&IsmDate::YearMonth(2003, 4)),
1874            Ordering::Equal
1875        );
1876    }
1877
1878    #[test]
1879    fn year_month_end_cmp_different_months_same_year() {
1880        // April ends Apr 30; May ends May 31 → May > April.
1881        assert_eq!(
1882            IsmDate::YearMonth(2003, 5).end_cmp(&IsmDate::YearMonth(2003, 4)),
1883            Ordering::Greater
1884        );
1885        assert_eq!(
1886            IsmDate::YearMonth(2003, 4).end_cmp(&IsmDate::YearMonth(2003, 5)),
1887            Ordering::Less
1888        );
1889    }
1890
1891    #[test]
1892    fn date_end_cmp_same_date_is_equal() {
1893        assert_eq!(
1894            IsmDate::Date(2003, 4, 15).end_cmp(&IsmDate::Date(2003, 4, 15)),
1895            Ordering::Equal
1896        );
1897    }
1898
1899    #[test]
1900    fn date_end_cmp_later_date_is_greater() {
1901        assert_eq!(
1902            IsmDate::Date(2003, 4, 16).end_cmp(&IsmDate::Date(2003, 4, 15)),
1903            Ordering::Greater
1904        );
1905    }
1906
1907    #[test]
1908    fn datetime_end_cmp_same_instant_is_equal() {
1909        let dt = IsmDate::DateTime {
1910            year: 2003,
1911            month: 4,
1912            day: 15,
1913            hour: 10,
1914            minute: 30,
1915            second: 45,
1916            nanosecond: 0,
1917            offset: None,
1918        };
1919        assert_eq!(dt.end_cmp(&dt.clone()), Ordering::Equal);
1920    }
1921
1922    #[test]
1923    fn datetime_end_cmp_later_second_is_greater() {
1924        let earlier = IsmDate::DateTime {
1925            year: 2003,
1926            month: 4,
1927            day: 15,
1928            hour: 10,
1929            minute: 30,
1930            second: 44,
1931            nanosecond: 0,
1932            offset: None,
1933        };
1934        let later = IsmDate::DateTime {
1935            year: 2003,
1936            month: 4,
1937            day: 15,
1938            hour: 10,
1939            minute: 30,
1940            second: 45,
1941            nanosecond: 0,
1942            offset: None,
1943        };
1944        assert_eq!(later.end_cmp(&earlier), Ordering::Greater);
1945        assert_eq!(earlier.end_cmp(&later), Ordering::Less);
1946    }
1947
1948    #[test]
1949    fn datetime_end_cmp_nanosecond_tiebreak() {
1950        let a = IsmDate::DateTime {
1951            year: 2003,
1952            month: 4,
1953            day: 15,
1954            hour: 10,
1955            minute: 30,
1956            second: 45,
1957            nanosecond: 0,
1958            offset: None,
1959        };
1960        let b = IsmDate::DateTime {
1961            year: 2003,
1962            month: 4,
1963            day: 15,
1964            hour: 10,
1965            minute: 30,
1966            second: 45,
1967            nanosecond: 1,
1968            offset: None,
1969        };
1970        assert_eq!(b.end_cmp(&a), Ordering::Greater);
1971    }
1972
1973    #[test]
1974    fn date_hour_min_floating_is_treated_as_offset_zero() {
1975        // Floating DateHourMin uses offset=0 for tie-breaking; same civil time
1976        // as UTC means they compare Equal.
1977        let floating = IsmDate::DateHourMin {
1978            year: 2003,
1979            month: 4,
1980            day: 15,
1981            hour: 10,
1982            minute: 30,
1983            offset: None,
1984        };
1985        let utc = IsmDate::DateHourMin {
1986            year: 2003,
1987            month: 4,
1988            day: 15,
1989            hour: 10,
1990            minute: 30,
1991            offset: Some(UtcOffset::UTC),
1992        };
1993        // utc_tie_break is -offset.minutes; for UTC that's 0; for floating that's
1994        // also 0. So they compare Equal on the tie-break.
1995        assert_eq!(floating.end_cmp(&utc), Ordering::Equal);
1996    }
1997
1998    #[test]
1999    fn year_end_cmp_vs_year_month_same_year() {
2000        // Year(2003) ends Dec 31 23:59:59; YearMonth(2003, 6) ends Jun 30 23:59:59.
2001        assert_eq!(
2002            IsmDate::Year(2003).end_cmp(&IsmDate::YearMonth(2003, 6)),
2003            Ordering::Greater
2004        );
2005        assert_eq!(
2006            IsmDate::YearMonth(2003, 12).end_cmp(&IsmDate::Year(2003)),
2007            Ordering::Equal // Dec 31 == Dec 31
2008        );
2009    }
2010
2011    // -----------------------------------------------------------------------
2012    // to_maxdate_str — DateHourMin and DateTime cases
2013    // -----------------------------------------------------------------------
2014
2015    #[test]
2016    fn to_maxdate_str_date_hour_min() {
2017        // DateHourMin uses the date component only.
2018        let t = IsmDate::DateHourMin {
2019            year: 2003,
2020            month: 4,
2021            day: 15,
2022            hour: 14,
2023            minute: 30,
2024            offset: None,
2025        };
2026        assert_eq!(&*t.to_maxdate_str(), "20030415");
2027    }
2028
2029    #[test]
2030    fn to_maxdate_str_datetime() {
2031        let dt = IsmDate::DateTime {
2032            year: 2003,
2033            month: 4,
2034            day: 15,
2035            hour: 14,
2036            minute: 30,
2037            second: 45,
2038            nanosecond: 0,
2039            offset: Some(UtcOffset::UTC),
2040        };
2041        assert_eq!(&*dt.to_maxdate_str(), "20030415");
2042    }
2043
2044    #[test]
2045    fn to_maxdate_str_all_months_days_in_month() {
2046        // Verify days_in_month for all 12 months in a non-leap year (2003).
2047        let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
2048        for (i, &days) in expected.iter().enumerate() {
2049            let month = (i + 1) as u8;
2050            let ym = IsmDate::YearMonth(2003, month);
2051            let s = ym.to_maxdate_str();
2052            let day_part: u8 = s[6..].parse().unwrap();
2053            assert_eq!(
2054                day_part, days,
2055                "2003-{month:02} should end on day {days}, got {day_part}"
2056            );
2057        }
2058    }
2059
2060    #[test]
2061    fn to_maxdate_str_february_leap_year() {
2062        // 2000 is a leap year: February has 29 days.
2063        assert_eq!(&*IsmDate::YearMonth(2000, 2).to_maxdate_str(), "20000229");
2064        // 1900 is NOT a leap year (divisible by 100 but not 400).
2065        assert_eq!(&*IsmDate::YearMonth(1900, 2).to_maxdate_str(), "19000228");
2066    }
2067
2068    // -----------------------------------------------------------------------
2069    // ApproxIsmDate Display
2070    // -----------------------------------------------------------------------
2071
2072    #[test]
2073    fn approx_ism_date_display_without_qualifier() {
2074        let a = ApproxIsmDate {
2075            date: IsmDate::Year(2003),
2076            qualifier: None,
2077        };
2078        assert_eq!(a.to_string(), "2003");
2079    }
2080
2081    #[test]
2082    fn approx_ism_date_display_with_qualifier() {
2083        let a = ApproxIsmDate {
2084            date: IsmDate::Year(1995),
2085            qualifier: Some(ApproxQualifier::Circa),
2086        };
2087        assert_eq!(a.to_string(), "circa 1995");
2088    }
2089
2090    #[test]
2091    fn approx_ism_date_display_all_qualifiers() {
2092        let pairs = [
2093            (ApproxQualifier::FirstQtr, "1st qtr 2003"),
2094            (ApproxQualifier::SecondQtr, "2nd qtr 2003"),
2095            (ApproxQualifier::ThirdQtr, "3rd qtr 2003"),
2096            (ApproxQualifier::FourthQtr, "4th qtr 2003"),
2097            (ApproxQualifier::Circa, "circa 2003"),
2098            (ApproxQualifier::Early, "early 2003"),
2099            (ApproxQualifier::Mid, "mid 2003"),
2100            (ApproxQualifier::Late, "late 2003"),
2101        ];
2102        for (qualifier, expected) in pairs {
2103            let a = ApproxIsmDate {
2104                date: IsmDate::Year(2003),
2105                qualifier: Some(qualifier),
2106            };
2107            assert_eq!(a.to_string(), expected, "qualifier={qualifier:?}");
2108        }
2109    }
2110
2111    // -----------------------------------------------------------------------
2112    // ParseIsmDateError and ParseApproxQualifierError Display
2113    // -----------------------------------------------------------------------
2114
2115    #[test]
2116    fn parse_ism_date_error_display() {
2117        let err = IsmDate::from_str("not-a-date").unwrap_err();
2118        let s = err.to_string();
2119        assert!(
2120            s.contains("invalid ISM date"),
2121            "error display should mention 'invalid ISM date', got: {s:?}"
2122        );
2123    }
2124
2125    #[test]
2126    fn parse_approx_qualifier_error_display() {
2127        let err = ApproxQualifier::from_str("bogus").unwrap_err();
2128        let s = err.to_string();
2129        assert!(
2130            s.contains("invalid approx qualifier"),
2131            "error display should mention 'invalid approx qualifier', got: {s:?}"
2132        );
2133    }
2134
2135    // -----------------------------------------------------------------------
2136    // Parsing edge cases not covered above
2137    // -----------------------------------------------------------------------
2138
2139    #[test]
2140    fn rejects_short_strings() {
2141        for s in ["", "2", "20", "200", "20030"] {
2142            assert!(
2143                IsmDate::from_str(s).is_err(),
2144                "should reject short string {s:?}"
2145            );
2146        }
2147    }
2148
2149    #[test]
2150    fn rejects_nine_char_string() {
2151        // 9 chars doesn't match any pattern.
2152        assert!(IsmDate::from_str("200304150").is_err());
2153    }
2154
2155    #[test]
2156    fn rejects_day_zero_in_date() {
2157        assert!(IsmDate::from_str("2003-04-00").is_err());
2158    }
2159
2160    #[test]
2161    fn rejects_day_32_in_date() {
2162        assert!(IsmDate::from_str("2003-01-32").is_err());
2163    }
2164
2165    #[test]
2166    fn rejects_yyyymmdd_month_13() {
2167        assert!(IsmDate::from_str("20031301").is_err());
2168    }
2169
2170    #[test]
2171    fn rejects_yyyymmdd_day_00() {
2172        assert!(IsmDate::from_str("20030400").is_err());
2173    }
2174
2175    #[test]
2176    fn rejects_datehourmin_hour_out_of_range() {
2177        assert!(IsmDate::from_str("2003-04-15T24:00").is_err());
2178        assert!(IsmDate::from_str("2003-04-15T25:00Z").is_err());
2179    }
2180
2181    #[test]
2182    fn rejects_datehourmin_minute_out_of_range() {
2183        assert!(IsmDate::from_str("2003-04-15T10:60").is_err());
2184        assert!(IsmDate::from_str("2003-04-15T10:99Z").is_err());
2185    }
2186
2187    #[test]
2188    fn rejects_datetime_second_out_of_range() {
2189        assert!(IsmDate::from_str("2003-04-15T10:30:60Z").is_err());
2190        assert!(IsmDate::from_str("2003-04-15T10:30:99").is_err());
2191    }
2192
2193    #[test]
2194    fn rejects_fractional_seconds_empty() {
2195        // A period with no digits after it is invalid.
2196        assert!(IsmDate::from_str("2003-04-15T10:30:00.Z").is_err());
2197        assert!(IsmDate::from_str("2003-04-15T10:30:00.").is_err());
2198    }
2199
2200    #[test]
2201    fn rejects_fractional_seconds_too_many_digits() {
2202        // More than 9 fractional digits must be rejected.
2203        assert!(IsmDate::from_str("2003-04-15T10:30:00.1234567890Z").is_err());
2204    }
2205
2206    #[test]
2207    fn accepts_fractional_seconds_9_digits() {
2208        // Exactly 9 digits (nanosecond precision) must be accepted.
2209        assert!(IsmDate::from_str("2003-04-15T10:30:00.123456789Z").is_ok());
2210    }
2211
2212    #[test]
2213    fn rejects_bad_offset_in_datetime() {
2214        assert!(IsmDate::from_str("2003-04-15T10:30:00+99:99").is_err());
2215        assert!(IsmDate::from_str("2003-04-15T10:30:00+24:00").is_err());
2216    }
2217
2218    #[test]
2219    fn rejects_bad_offset_in_datehourmin() {
2220        assert!(IsmDate::from_str("2003-04-15T10:30+99:99").is_err());
2221        assert!(IsmDate::from_str("2003-04-15T10:30+24:00").is_err());
2222    }
2223
2224    #[test]
2225    fn rejects_unknown_suffix_after_datehourmin() {
2226        // Anything after HH:MM that is not empty, Z, or ±HH:MM is invalid.
2227        assert!(IsmDate::from_str("2003-04-15T10:30:garbage").is_err());
2228    }
2229
2230    #[test]
2231    fn rejects_year_with_non_digit_separator() {
2232        // 7-char string where bytes[4] != b'-' falls to the catch-all error.
2233        assert!(IsmDate::from_str("2003X04").is_err());
2234    }
2235
2236    #[test]
2237    fn rejects_date_with_wrong_separator() {
2238        assert!(IsmDate::from_str("2003/04/15").is_err());
2239    }
2240
2241    #[test]
2242    fn round_trip_datetime_with_nanos() {
2243        // 9-digit fractional seconds round-trips.
2244        assert!(round_trip("2003-04-15T14:30:00.123456789Z"));
2245    }
2246
2247    #[test]
2248    fn round_trip_datetime_with_negative_offset() {
2249        assert!(round_trip("2003-04-15T14:30:00-05:00"));
2250    }
2251
2252    #[test]
2253    fn round_trip_date_hour_min_negative_offset() {
2254        assert!(round_trip("2003-04-15T14:30-07:00"));
2255    }
2256
2257    #[test]
2258    fn round_trip_year_month_january() {
2259        assert!(round_trip("2003-01"));
2260    }
2261
2262    #[test]
2263    fn round_trip_year_month_december() {
2264        assert!(round_trip("2003-12"));
2265    }
2266
2267    #[test]
2268    fn capco_yyyymmdd_rejects_invalid_calendar_date() {
2269        // YYYYMMDD with month 13 must not silently succeed.
2270        assert!(IsmDate::from_str("20031301").is_err());
2271        // YYYYMMDD with day 0 must fail.
2272        assert!(IsmDate::from_str("20030400").is_err());
2273        // Non-leap February 29.
2274        assert!(IsmDate::from_str("20030229").is_err());
2275    }
2276
2277    #[test]
2278    fn utc_offset_from_str_all_canonical_forms() {
2279        // Positive offset round-trips correctly.
2280        let o: UtcOffset = "+12:00".parse().unwrap();
2281        assert_eq!(o.minutes, 720);
2282        assert_eq!(o.to_string(), "+12:00");
2283
2284        // Negative offset round-trips correctly.
2285        let o: UtcOffset = "-12:00".parse().unwrap();
2286        assert_eq!(o.minutes, -720);
2287        assert_eq!(o.to_string(), "-12:00");
2288    }
2289}