Skip to main content

fhir_core/
temporal.rs

1//! Parsing and precision-aware comparison for FHIR date/time primitives.
2//!
3//! FHIR stores `date`, `dateTime`, `instant`, and `time` as strings, and this
4//! crate keeps that representation unchanged. This module adds *reading*
5//! helpers on top: it parses a value into its calendar/clock components and
6//! compares two partial dates per the FHIR precision rules, where a comparison
7//! between values of different precision may be **indeterminate**.
8//!
9//! The rules are the same in every FHIR release, so the parsing lives here and
10//! each release only attaches it to its own primitive newtypes — see
11//! [`r4::temporal`](crate::r4::temporal) and [`r5::temporal`](crate::r5::temporal).
12//!
13//! ```
14//! use fhir::temporal::{DateParts, DatePrecision};
15//!
16//! let parts = DateParts::parse("2024-03").unwrap();
17//! assert_eq!(parts.year, 2024);
18//! assert_eq!(parts.month, Some(3));
19//! assert_eq!(parts.day, None);
20//! assert_eq!(parts.precision(), DatePrecision::Month);
21//! ```
22
23use std::cmp::Ordering;
24
25/// The calendar precision of a `date`/`dateTime` value.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DatePrecision {
28    /// `YYYY`
29    Year,
30    /// `YYYY-MM`
31    Month,
32    /// `YYYY-MM-DD`
33    Day,
34}
35
36/// The calendar components of a FHIR `date` (or the date part of a `dateTime`).
37///
38/// `PartialOrd` follows the FHIR rule: two values compare only when the answer
39/// is definite. `"2024"` vs `"2025-03"` is `Some(Less)` (different years), but
40/// `"2024"` vs `"2024-03"` is `None` — the year-precision value spans the month,
41/// so the order is indeterminate.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct DateParts {
44    /// Four-digit year (0001–9999 in FHIR).
45    pub year: i32,
46    /// Month `1..=12`, if present.
47    pub month: Option<u8>,
48    /// Day `1..=31`, if present (only when `month` is present).
49    pub day: Option<u8>,
50}
51
52impl DateParts {
53    /// The precision implied by which components are present.
54    #[must_use]
55    pub fn precision(&self) -> DatePrecision {
56        match (self.month, self.day) {
57            (None, _) => DatePrecision::Year,
58            (Some(_), None) => DatePrecision::Month,
59            (Some(_), Some(_)) => DatePrecision::Day,
60        }
61    }
62
63    /// Parse `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. Returns `None` if malformed or
64    /// out of range.
65    #[must_use]
66    pub fn parse(s: &str) -> Option<Self> {
67        let mut it = s.split('-');
68        let year: i32 = it.next()?.parse().ok()?;
69        if !(1..=9999).contains(&year) {
70            return None;
71        }
72        let month = match it.next() {
73            Some(m) => {
74                let m: u8 = m.parse().ok()?;
75                if !(1..=12).contains(&m) {
76                    return None;
77                }
78                Some(m)
79            }
80            None => None,
81        };
82        let day = match it.next() {
83            Some(d) => {
84                month?; // a day without a month is malformed
85                let d: u8 = d.parse().ok()?;
86                if !(1..=31).contains(&d) {
87                    return None;
88                }
89                Some(d)
90            }
91            None => None,
92        };
93        if it.next().is_some() {
94            return None; // trailing junk
95        }
96        Some(DateParts { year, month, day })
97    }
98}
99
100impl PartialOrd for DateParts {
101    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
102        if self.year != other.year {
103            return Some(self.year.cmp(&other.year));
104        }
105        indeterminate_or_equal(*self, *other)
106    }
107}
108
109/// Resolve the same-year case: equal when both stop at the same
110/// precision, a definite order when the finest shared component differs, or
111/// `None` when one side is less precise than the other.
112fn indeterminate_or_equal(a: DateParts, b: DateParts) -> Option<Ordering> {
113    match (a.month, b.month) {
114        (None, None) => Some(Ordering::Equal),
115        (Some(_), None) | (None, Some(_)) => None,
116        (Some(m1), Some(m2)) => {
117            if m1 != m2 {
118                return Some(m1.cmp(&m2));
119            }
120            match (a.day, b.day) {
121                (None, None) => Some(Ordering::Equal),
122                (Some(_), None) | (None, Some(_)) => None,
123                (Some(d1), Some(d2)) => Some(d1.cmp(&d2)),
124            }
125        }
126    }
127}
128
129/// The clock components of a FHIR `time` (`hh:mm:ss` with optional fraction).
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct TimeParts {
132    /// Hour `0..=23`.
133    pub hour: u8,
134    /// Minute `0..=59`.
135    pub minute: u8,
136    /// Second `0..=59` (FHIR also permits `60` for leap seconds).
137    pub second: u8,
138    /// Fractional-seconds digits, if present (e.g. `"250"` for `.250`).
139    pub fraction: Option<String>,
140}
141
142impl TimeParts {
143    /// Parse `hh:mm:ss` or `hh:mm:ss.fff`. Returns `None` if malformed.
144    #[must_use]
145    pub fn parse(s: &str) -> Option<Self> {
146        let (hms, fraction) = match s.split_once('.') {
147            Some((h, f)) if f.chars().all(|c| c.is_ascii_digit()) && !f.is_empty() => {
148                (h, Some(f.to_string()))
149            }
150            Some(_) => return None,
151            None => (s, None),
152        };
153        let mut it = hms.split(':');
154        let hour: u8 = it.next()?.parse().ok()?;
155        let minute: u8 = it.next()?.parse().ok()?;
156        let second: u8 = it.next()?.parse().ok()?;
157        if it.next().is_some() || hour > 23 || minute > 59 || second > 60 {
158            return None;
159        }
160        Some(TimeParts {
161            hour,
162            minute,
163            second,
164            fraction,
165        })
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn parse_date_precisions() {
175        assert_eq!(
176            DateParts::parse("2024").unwrap(),
177            DateParts {
178                year: 2024,
179                month: None,
180                day: None
181            }
182        );
183        assert_eq!(
184            DateParts::parse("2024-03").unwrap().precision(),
185            DatePrecision::Month
186        );
187        assert_eq!(
188            DateParts::parse("2024-03-25").unwrap().precision(),
189            DatePrecision::Day
190        );
191    }
192
193    #[test]
194    fn rejects_malformed_dates() {
195        assert!(DateParts::parse("2024-13").is_none()); // bad month
196        assert!(DateParts::parse("2024-03-32").is_none()); // bad day
197        assert!(DateParts::parse("2024-03-25T00:00").is_none()); // not a date
198        assert!(DateParts::parse("").is_none());
199    }
200
201    #[test]
202    fn date_ordering_same_precision() {
203        let a = DateParts::parse("2024-03").unwrap();
204        let b = DateParts::parse("2024-05").unwrap();
205        assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
206        assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater));
207        assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal));
208    }
209
210    #[test]
211    fn date_ordering_different_precision() {
212        let year = DateParts::parse("2024").unwrap();
213        let month = DateParts::parse("2024-03").unwrap();
214        // Same year, different precision -> indeterminate.
215        assert_eq!(year.partial_cmp(&month), None);
216        // Different year -> definite regardless of precision.
217        let other = DateParts::parse("2025-03").unwrap();
218        assert_eq!(year.partial_cmp(&other), Some(Ordering::Less));
219    }
220
221    #[test]
222    fn parse_time() {
223        let t = TimeParts::parse("13:28:17").unwrap();
224        assert_eq!((t.hour, t.minute, t.second), (13, 28, 17));
225        assert_eq!(t.fraction, None);
226        assert_eq!(
227            TimeParts::parse("13:28:17.250").unwrap().fraction,
228            Some("250".into())
229        );
230        assert!(TimeParts::parse("25:00:00").is_none());
231    }
232}