fhir 1.0.0

Fast Healthcare Interoperability Resources (FHIR) API is a standardized, RESTful interface for exchanging electronic health records.
Documentation
//! Parsing and precision-aware comparison for FHIR date/time primitives.
//!
//! FHIR stores `date`, `dateTime`, `instant`, and `time` as strings, and this
//! crate keeps that representation unchanged. This module adds *reading* helpers
//! on top: it parses a value into its calendar/clock components and compares two
//! partial dates per the FHIR precision rules, where a comparison between values
//! of different precision may be **indeterminate**.
//!
//! ```
//! use fhir::r5::types::Date;
//! use fhir::r5::temporal::DatePrecision;
//!
//! let d = Date("2024-03".to_string());
//! let parts = d.parse_parts().unwrap();
//! assert_eq!(parts.year, 2024);
//! assert_eq!(parts.month, Some(3));
//! assert_eq!(parts.day, None);
//! assert_eq!(parts.precision(), DatePrecision::Month);
//! ```

use std::cmp::Ordering;

use crate::r5::types::{Date, DateTime, Instant, Time};

/// The calendar precision of a `date`/`dateTime` value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatePrecision {
    /// `YYYY`
    Year,
    /// `YYYY-MM`
    Month,
    /// `YYYY-MM-DD`
    Day,
}

/// The calendar components of a FHIR `date` (or the date part of a `dateTime`).
///
/// `PartialOrd` follows the FHIR rule: two values compare only when the answer
/// is definite. `"2024"` vs `"2025-03"` is `Some(Less)` (different years), but
/// `"2024"` vs `"2024-03"` is `None` — the year-precision value spans the month,
/// so the order is indeterminate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DateParts {
    /// Four-digit year (0001–9999 in FHIR).
    pub year: i32,
    /// Month `1..=12`, if present.
    pub month: Option<u8>,
    /// Day `1..=31`, if present (only when `month` is present).
    pub day: Option<u8>,
}

impl DateParts {
    /// The precision implied by which components are present.
    #[must_use]
    pub fn precision(&self) -> DatePrecision {
        match (self.month, self.day) {
            (None, _) => DatePrecision::Year,
            (Some(_), None) => DatePrecision::Month,
            (Some(_), Some(_)) => DatePrecision::Day,
        }
    }

    /// Parse `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. Returns `None` if malformed or
    /// out of range.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        let mut it = s.split('-');
        let year: i32 = it.next()?.parse().ok()?;
        if !(1..=9999).contains(&year) {
            return None;
        }
        let month = match it.next() {
            Some(m) => {
                let m: u8 = m.parse().ok()?;
                if !(1..=12).contains(&m) {
                    return None;
                }
                Some(m)
            }
            None => None,
        };
        let day = match it.next() {
            Some(d) => {
                month?; // a day without a month is malformed
                let d: u8 = d.parse().ok()?;
                if !(1..=31).contains(&d) {
                    return None;
                }
                Some(d)
            }
            None => None,
        };
        if it.next().is_some() {
            return None; // trailing junk
        }
        Some(DateParts { year, month, day })
    }
}

impl PartialOrd for DateParts {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self.year != other.year {
            return Some(self.year.cmp(&other.year));
        }
        indeterminate_or_equal(*self, *other)
    }
}

/// Resolve the same-year case: equal when both stop at the same
/// precision, a definite order when the finest shared component differs, or
/// `None` when one side is less precise than the other.
fn indeterminate_or_equal(a: DateParts, b: DateParts) -> Option<Ordering> {
    match (a.month, b.month) {
        (None, None) => Some(Ordering::Equal),
        (Some(_), None) | (None, Some(_)) => None,
        (Some(m1), Some(m2)) => {
            if m1 != m2 {
                return Some(m1.cmp(&m2));
            }
            match (a.day, b.day) {
                (None, None) => Some(Ordering::Equal),
                (Some(_), None) | (None, Some(_)) => None,
                (Some(d1), Some(d2)) => Some(d1.cmp(&d2)),
            }
        }
    }
}

/// The clock components of a FHIR `time` (`hh:mm:ss` with optional fraction).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeParts {
    /// Hour `0..=23`.
    pub hour: u8,
    /// Minute `0..=59`.
    pub minute: u8,
    /// Second `0..=59` (FHIR also permits `60` for leap seconds).
    pub second: u8,
    /// Fractional-seconds digits, if present (e.g. `"250"` for `.250`).
    pub fraction: Option<String>,
}

impl TimeParts {
    /// Parse `hh:mm:ss` or `hh:mm:ss.fff`. Returns `None` if malformed.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        let (hms, fraction) = match s.split_once('.') {
            Some((h, f)) if f.chars().all(|c| c.is_ascii_digit()) && !f.is_empty() => {
                (h, Some(f.to_string()))
            }
            Some(_) => return None,
            None => (s, None),
        };
        let mut it = hms.split(':');
        let hour: u8 = it.next()?.parse().ok()?;
        let minute: u8 = it.next()?.parse().ok()?;
        let second: u8 = it.next()?.parse().ok()?;
        if it.next().is_some() || hour > 23 || minute > 59 || second > 60 {
            return None;
        }
        Some(TimeParts { hour, minute, second, fraction })
    }
}

impl Date {
    /// Parse this `date` into its calendar components.
    #[must_use]
    pub fn parse_parts(&self) -> Option<DateParts> {
        DateParts::parse(&self.0)
    }
}

impl DateTime {
    /// Parse the calendar (date) part of this `dateTime`. The value may be just
    /// a date (`2024`, `2024-03`) or a full timestamp; this returns the date
    /// components in either case.
    #[must_use]
    pub fn date_parts(&self) -> Option<DateParts> {
        let date = self.0.split(['T', 't']).next().unwrap_or(&self.0);
        DateParts::parse(date)
    }
}

impl Instant {
    /// Parse the calendar (date) part of this `instant`.
    #[must_use]
    pub fn date_parts(&self) -> Option<DateParts> {
        let date = self.0.split(['T', 't']).next().unwrap_or(&self.0);
        DateParts::parse(date)
    }
}

impl Time {
    /// Parse this `time` into its clock components.
    #[must_use]
    pub fn parse_parts(&self) -> Option<TimeParts> {
        TimeParts::parse(&self.0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_date_precisions() {
        assert_eq!(
            Date("2024".into()).parse_parts().unwrap(),
            DateParts { year: 2024, month: None, day: None }
        );
        assert_eq!(
            Date("2024-03".into()).parse_parts().unwrap().precision(),
            DatePrecision::Month
        );
        assert_eq!(
            Date("2024-03-25".into()).parse_parts().unwrap().precision(),
            DatePrecision::Day
        );
    }

    #[test]
    fn rejects_malformed_dates() {
        assert!(Date("2024-13".into()).parse_parts().is_none()); // bad month
        assert!(Date("2024-03-32".into()).parse_parts().is_none()); // bad day
        assert!(Date("2024-03-25T00:00".into()).parse_parts().is_none()); // not a date
        assert!(Date(String::new()).parse_parts().is_none());
    }

    #[test]
    fn date_ordering_same_precision() {
        let a = Date("2024-03".into()).parse_parts().unwrap();
        let b = Date("2024-05".into()).parse_parts().unwrap();
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
        assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater));
        assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal));
    }

    #[test]
    fn date_ordering_different_precision() {
        let year = Date("2024".into()).parse_parts().unwrap();
        let month = Date("2024-03".into()).parse_parts().unwrap();
        // Same year, different precision -> indeterminate.
        assert_eq!(year.partial_cmp(&month), None);
        // Different year -> definite regardless of precision.
        let other = Date("2025-03".into()).parse_parts().unwrap();
        assert_eq!(year.partial_cmp(&other), Some(Ordering::Less));
    }

    #[test]
    fn datetime_and_instant_date_part() {
        let dt = DateTime("2015-02-07T13:28:17-05:00".into());
        assert_eq!(dt.date_parts().unwrap().precision(), DatePrecision::Day);
        let inst = Instant("2015-02-07T13:28:17.239+02:00".into());
        assert_eq!(inst.date_parts().unwrap().day, Some(7));
    }

    #[test]
    fn parse_time() {
        let t = Time("13:28:17".into()).parse_parts().unwrap();
        assert_eq!((t.hour, t.minute, t.second), (13, 28, 17));
        assert_eq!(t.fraction, None);
        assert_eq!(Time("13:28:17.250".into()).parse_parts().unwrap().fraction, Some("250".into()));
        assert!(Time("25:00:00".into()).parse_parts().is_none());
    }
}