Skip to main content

haste_fhir_model/r4/datetime/
mod.rs

1use chrono::{Datelike, Days, NaiveDate};
2use regex::Regex;
3use std::sync::LazyLock;
4
5mod reflect;
6mod serialize;
7
8#[cfg(test)]
9mod proptests;
10
11/// `year` as four digits. The integer fast path, padded only when short.
12#[inline]
13fn padded_year(year: u16) -> String {
14    let mut text = year.to_string();
15    if text.len() < 4 {
16        text.insert_str(0, &"000"[..4 - text.len()]);
17    }
18    text
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum DateTime {
23    Year(u16),
24    YearMonth(u16, u8),
25    YearMonthDay(u16, u8, u8),
26    Iso8601(chrono::DateTime<chrono::Utc>),
27}
28
29impl ToString for DateTime {
30    fn to_string(&self) -> String {
31        match self {
32            DateTime::Year(year) => padded_year(*year),
33            DateTime::YearMonth(year, month) => format!("{:04}-{:02}", year, month),
34            DateTime::YearMonthDay(year, month, day) => {
35                format!("{:04}-{:02}-{:02}", year, month, day)
36            }
37            DateTime::Iso8601(dt) => dt.to_rfc3339(),
38        }
39    }
40}
41
42impl TryFrom<DateTime> for chrono::DateTime<chrono::Utc> {
43    type Error = ParseError;
44
45    fn try_from(value: DateTime) -> Result<Self, Self::Error> {
46        match value {
47            // "1996-12-19T16:39:57-08:00"
48            DateTime::Year(year) => {
49                let datetime = chrono::DateTime::parse_from_rfc3339(
50                    format!("{}-01-01T00:00:00Z", year).as_str(),
51                )
52                .map_err(|_| ParseError::InvalidFormat)?;
53
54                Ok(datetime.with_timezone(&chrono::Utc))
55            }
56            DateTime::YearMonth(year, month) => {
57                let datetime = chrono::DateTime::parse_from_rfc3339(
58                    format!("{}-{:02}-01T00:00:00Z", year, month).as_str(),
59                )
60                .map_err(|_| ParseError::InvalidFormat)?;
61
62                Ok(datetime.with_timezone(&chrono::Utc))
63            }
64            DateTime::YearMonthDay(year, month, day) => {
65                let datetime = chrono::DateTime::parse_from_rfc3339(
66                    format!("{}-{:02}-{:02}T00:00:00Z", year, month, day).as_str(),
67                )
68                .map_err(|_| ParseError::InvalidFormat)?;
69
70                Ok(datetime.with_timezone(&chrono::Utc))
71            }
72            DateTime::Iso8601(dt) => Ok(dt),
73        }
74    }
75}
76
77#[derive(Debug, Clone, PartialEq)]
78pub enum Date {
79    Year(u16),
80    YearMonth(u16, u8),
81    YearMonthDay(u16, u8, u8),
82}
83
84impl ToString for Date {
85    fn to_string(&self) -> String {
86        match self {
87            Date::Year(year) => padded_year(*year),
88            Date::YearMonth(year, month) => format!("{:04}-{:02}", year, month),
89            Date::YearMonthDay(year, month, day) => {
90                format!("{:04}-{:02}-{:02}", year, month, day)
91            }
92        }
93    }
94}
95
96#[derive(Debug, Clone, PartialEq)]
97pub enum Instant {
98    Iso8601(chrono::DateTime<chrono::Utc>),
99}
100
101impl Instant {
102    pub fn format(&self, fmt: &str) -> String {
103        match self {
104            Instant::Iso8601(dt) => dt.to_utc().format(fmt).to_string(),
105        }
106    }
107}
108
109impl ToString for Instant {
110    fn to_string(&self) -> String {
111        match self {
112            Instant::Iso8601(dt) => dt.to_rfc3339(),
113        }
114    }
115}
116
117/// A time of day. FHIR's `time` always has seconds; FHIRPath's also allows
118/// hour and minute precision.
119#[derive(Debug, Clone, PartialEq)]
120pub enum Time {
121    Hour(u8),
122    HourMinute(u8, u8),
123    HourMinuteSecond(chrono::NaiveTime),
124}
125
126impl ToString for Time {
127    fn to_string(&self) -> String {
128        match self {
129            Time::Hour(hour) => format!("{hour:02}"),
130            Time::HourMinute(hour, minute) => format!("{hour:02}:{minute:02}"),
131            Time::HourMinuteSecond(time) => time.format("%H:%M:%S%.f").to_string(),
132        }
133    }
134}
135
136#[derive(Debug)]
137pub enum ParseError {
138    InvalidFormat,
139}
140
141pub static DATE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
142    Regex::new(
143        r"^(?<year>[0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(?<month>0[1-9]|1[0-2])(-(?<day>0[1-9]|[1-2][0-9]|3[0-1]))?)?$",
144    ).unwrap()
145});
146
147pub static DATETIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
148    Regex::new(
149        r"^(?<year>[0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(?<month>0[1-9]|1[0-2])(-(?<day>0[1-9]|[1-2][0-9]|3[0-1])(?<time>T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?$",
150    ).unwrap()
151});
152
153pub static INSTANT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
154    Regex::new(
155        r"^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$",
156    ).unwrap()
157});
158
159pub static TIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
160    Regex::new(r"^([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?$").unwrap()
161});
162
163/// A FHIR `time`, or one cut off after the hour or minute.
164static PARTIAL_TIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
165    Regex::new(r"^([01][0-9]|2[0-3])(?::([0-5][0-9])(?::([0-5][0-9]|60)(\.[0-9]+)?)?)?$").unwrap()
166});
167
168/// A capture the regex matched as digits.
169#[inline]
170fn captured<T: std::str::FromStr>(capture: regex::Match<'_>) -> T
171where
172    T::Err: std::fmt::Debug,
173{
174    capture.as_str().parse().expect("the regex matched digits")
175}
176
177/// The calendar date `day` of `month` names. The regexes admit any day up
178/// to 31; a day the month lacks carries into the next month as chrono
179/// counts from the first: `2023-02-30` is `2023-03-02`, `2024-06-31` is
180/// `2024-07-01`. Every month has 28 days, so only a later day is checked.
181#[inline]
182fn calendar_date(year: u16, month: u8, day: u8) -> Result<(u16, u8, u8), ParseError> {
183    let (y, m) = (i32::from(year), u32::from(month));
184    if day <= 28 || NaiveDate::from_ymd_opt(y, m, u32::from(day)).is_some() {
185        return Ok((year, month, day));
186    }
187    let carried = NaiveDate::from_ymd_opt(y, m, 1)
188        .and_then(|first| first.checked_add_days(Days::new(u64::from(day).saturating_sub(1))))
189        .ok_or(ParseError::InvalidFormat)?;
190    Ok((
191        u16::try_from(carried.year()).map_err(|_| ParseError::InvalidFormat)?,
192        u8::try_from(carried.month()).map_err(|_| ParseError::InvalidFormat)?,
193        u8::try_from(carried.day()).map_err(|_| ParseError::InvalidFormat)?,
194    ))
195}
196
197/// RFC 3339 text a regex matched, parsed. Chrono rejects a day the month
198/// lacks, so that text is parsed again with the day carried over
199/// ([`calendar_date`]); the regex fixes the date at ten characters.
200#[inline]
201fn parse_rfc3339(text: &str) -> Result<chrono::DateTime<chrono::FixedOffset>, ParseError> {
202    if let Ok(datetime) = chrono::DateTime::parse_from_rfc3339(text) {
203        return Ok(datetime);
204    }
205    let (date, time) = text.split_at(10);
206    let (year, rest) = date.split_once('-').ok_or(ParseError::InvalidFormat)?;
207    let (month, day) = rest.split_once('-').ok_or(ParseError::InvalidFormat)?;
208    let (year, month, day) = calendar_date(
209        year.parse().map_err(|_| ParseError::InvalidFormat)?,
210        month.parse().map_err(|_| ParseError::InvalidFormat)?,
211        day.parse().map_err(|_| ParseError::InvalidFormat)?,
212    )?;
213    chrono::DateTime::parse_from_rfc3339(&format!("{year:04}-{month:02}-{day:02}{time}"))
214        .map_err(|_| ParseError::InvalidFormat)
215}
216
217pub fn parse_instant(instant_string: &str) -> Result<Instant, ParseError> {
218    if INSTANT_REGEX.is_match(instant_string) {
219        let datetime = parse_rfc3339(instant_string)?;
220        Ok(Instant::Iso8601(datetime.with_timezone(&chrono::Utc)))
221    } else {
222        Err(ParseError::InvalidFormat)
223    }
224}
225
226pub fn parse_time(time_string: &str) -> Result<Time, ParseError> {
227    if TIME_REGEX.is_match(time_string) {
228        chrono::NaiveTime::parse_from_str(time_string, "%H:%M:%S%.f")
229            .map(Time::HourMinuteSecond)
230            .map_err(|_| ParseError::InvalidFormat)
231    } else {
232        Err(ParseError::InvalidFormat)
233    }
234}
235
236/// A FHIRPath Time, which unlike a FHIR `time` may stop at hour or minute.
237pub fn parse_partial_time(time_string: &str) -> Result<Time, ParseError> {
238    let captures = PARTIAL_TIME_REGEX
239        .captures(time_string)
240        .ok_or(ParseError::InvalidFormat)?;
241    match (captures.get(1), captures.get(2)) {
242        (Some(hour), None) => Ok(Time::Hour(captured(hour))),
243        (Some(hour), Some(minute)) if time_string.len() == 5 => {
244            Ok(Time::HourMinute(captured(hour), captured(minute)))
245        }
246        _ => parse_time(time_string),
247    }
248}
249
250pub fn parse_datetime(datetime_string: &str) -> Result<DateTime, ParseError> {
251    if let Some(captures) = DATETIME_REGEX.captures(datetime_string) {
252        match (
253            captures.name("year"),
254            captures.name("month"),
255            captures.name("day"),
256            captures.name("time"),
257        ) {
258            (Some(year), None, None, None) => Ok(DateTime::Year(captured(year))),
259            (Some(year), Some(month), None, None) => {
260                Ok(DateTime::YearMonth(captured(year), captured(month)))
261            }
262            (Some(year), Some(month), Some(day), None) => {
263                let (year, month, day) =
264                    calendar_date(captured(year), captured(month), captured(day))?;
265                Ok(DateTime::YearMonthDay(year, month, day))
266            }
267            (Some(_), Some(_), Some(_), Some(_)) => {
268                let datetime = parse_rfc3339(datetime_string)?;
269                Ok(DateTime::Iso8601(datetime.with_timezone(&chrono::Utc)))
270            }
271            _ => Err(ParseError::InvalidFormat),
272        }
273    } else {
274        Err(ParseError::InvalidFormat)
275    }
276}
277
278pub fn parse_date(date_string: &str) -> Result<Date, ParseError> {
279    if let Some(captures) = DATE_REGEX.captures(date_string) {
280        match (
281            captures.name("year"),
282            captures.name("month"),
283            captures.name("day"),
284        ) {
285            (Some(year), None, None) => Ok(Date::Year(captured(year))),
286            (Some(year), Some(month), None) => Ok(Date::YearMonth(captured(year), captured(month))),
287            (Some(year), Some(month), Some(day)) => {
288                let (year, month, day) =
289                    calendar_date(captured(year), captured(month), captured(day))?;
290                Ok(Date::YearMonthDay(year, month, day))
291            }
292            _ => Err(ParseError::InvalidFormat),
293        }
294    } else {
295        Err(ParseError::InvalidFormat)
296    }
297}
298
299pub enum DateKind {
300    DateTime,
301    Date,
302    Time,
303    Instant,
304}
305
306pub enum DateResult {
307    DateTime(DateTime),
308    Date(Date),
309    Time(Time),
310    Instant(Instant),
311}
312
313pub fn parse(kind: DateKind, input: &str) -> Result<DateResult, ParseError> {
314    match kind {
315        DateKind::DateTime => Ok(DateResult::DateTime(parse_datetime(input)?)),
316        DateKind::Date => Ok(DateResult::Date(parse_date(input)?)),
317        DateKind::Time => Ok(DateResult::Time(parse_time(input)?)),
318        DateKind::Instant => Ok(DateResult::Instant(parse_instant(input)?)),
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_parse_time() {
328        assert!(parse_time("12:34:56").is_ok());
329        assert!(parse_time("23:59:59").is_ok());
330        assert!(parse_time("23:59:59.232").is_ok());
331        assert_eq!(
332            parse_time("23:59:59.232").unwrap(),
333            Time::HourMinuteSecond(chrono::NaiveTime::from_hms_milli_opt(23, 59, 59, 232).unwrap())
334        );
335    }
336
337    #[test]
338    fn test_parse_instant() {
339        assert!(parse_instant("2015-02-07T13:28:17.239+02:00").is_ok());
340        assert!(parse_instant("2017-01-01T00:00:00Z").is_ok());
341    }
342
343    #[test]
344    fn test_parse_date() {
345        assert_eq!(parse_date("2023").unwrap(), Date::Year(2023));
346        assert_eq!(parse_date("2023-01").unwrap(), Date::YearMonth(2023, 1));
347        assert_eq!(
348            parse_date("2023-01-01").unwrap(),
349            Date::YearMonthDay(2023, 1, 1)
350        );
351
352        assert_eq!(
353            Date::YearMonthDay(2023, 1, 19),
354            parse_date("2023-01-19").unwrap()
355        );
356
357        assert!(parse_date("2023-01-33").is_err());
358        assert!(parse_date("2023-13-30").is_err());
359        assert!(parse_date("2023-01-01T12:00:00Z").is_err());
360    }
361    #[test]
362    fn test_parse_datetime() {
363        assert_eq!(parse_datetime("2023").unwrap(), DateTime::Year(2023));
364        assert_eq!(
365            parse_datetime("2023-01").unwrap(),
366            DateTime::YearMonth(2023, 1)
367        );
368        assert_eq!(
369            parse_datetime("2023-01-01").unwrap(),
370            DateTime::YearMonthDay(2023, 1, 1)
371        );
372
373        assert_eq!(
374            DateTime::YearMonthDay(2023, 1, 19),
375            parse_datetime("2023-01-19").unwrap()
376        );
377
378        // Invalid day won't parse.
379        assert!(parse_datetime("2023-01-42").is_err());
380
381        assert_eq!(
382            parse_datetime("2023-01-01T12:00:00Z").unwrap(),
383            DateTime::Iso8601(
384                chrono::DateTime::parse_from_rfc3339("2023-01-01T12:00:00Z")
385                    .unwrap()
386                    .with_timezone(&chrono::Utc)
387            )
388        );
389        assert!(parse_datetime("2023-01-01T12:00:00+00:00").is_ok());
390        assert!(parse_datetime("2023-01-01T12:00:00+01:00").is_ok());
391        assert!(parse_datetime("2023-01-01T12:00:00-01:00").is_ok());
392        assert!(parse_datetime("2023-01-01T12:00:00+02:00").is_ok());
393        assert!(parse_datetime("2023-01-01T12:00:00-02:00").is_ok());
394        assert!(parse_datetime("2023-01-01T12:00:00+14:00").is_ok());
395    }
396}