Skip to main content

fml_xml/
datetime.rs

1//! Dates and times in the lexical form a logical model declares with
2//! `elementdefinition-date-format` (`YYYYMMDDHHMMSS.UUUU[+|-ZZzz]`), and the FHIR form maps work
3//! with (`YYYY-MM-DDTHH:MM:SS.UUUU+ZZ:zz`).
4//!
5//! The conversion keeps the precision as written: `202001` becomes `2020-01`, `202001011230`
6//! becomes `2020-01-01T12:30`, and a time zone stays where it is. Going back gives the same text,
7//! so reading and writing a document changes nothing. FHIR's own `dateTime` needs seconds and a
8//! zone once there is a time; turning the value into a valid FHIR `dateTime` is the map's choice
9//! (the precision the document had is information the map may want).
10
11/// The FHIR-style form of a compact timestamp, or `None` when `text` is not one.
12pub fn from_compact(text: &str) -> Option<String> {
13    let (main, zone) = split_zone(text)?;
14    let (digits, fraction) = match main.split_once('.') {
15        Some((digits, fraction)) => (digits, Some(fraction)),
16        None => (main, None),
17    };
18    if !digits.bytes().all(|b| b.is_ascii_digit())
19        || !matches!(digits.len(), 4 | 6 | 8 | 10 | 12 | 14)
20    {
21        return None;
22    }
23    if fraction.is_some_and(|f| f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit()))
24        || (fraction.is_some() && digits.len() != 14)
25    {
26        return None;
27    }
28    let mut out = String::with_capacity(text.len() + 8);
29    out.push_str(&digits[0..4]);
30    if digits.len() >= 6 {
31        out.push('-');
32        out.push_str(&digits[4..6]);
33    }
34    if digits.len() >= 8 {
35        out.push('-');
36        out.push_str(&digits[6..8]);
37    }
38    if digits.len() >= 10 {
39        out.push('T');
40        out.push_str(&digits[8..10]);
41    }
42    if digits.len() >= 12 {
43        out.push(':');
44        out.push_str(&digits[10..12]);
45    }
46    if digits.len() >= 14 {
47        out.push(':');
48        out.push_str(&digits[12..14]);
49    }
50    if let Some(fraction) = fraction {
51        out.push('.');
52        out.push_str(fraction);
53    }
54    if let Some((sign, hours, minutes)) = zone {
55        out.push(sign);
56        out.push_str(hours);
57        out.push(':');
58        out.push_str(minutes);
59    }
60    Some(out)
61}
62
63/// The compact form of a FHIR-style date or time (the inverse of [`from_compact`]); `Z` is
64/// written as `+0000`. `None` when `text` is not in that form.
65pub fn to_compact(text: &str) -> Option<String> {
66    if text.is_empty() {
67        return None;
68    }
69    let bytes = text.as_bytes();
70    let mut out = String::with_capacity(text.len());
71    let mut index = 0;
72    // Date and time digits, each group with the separator before it.
73    let groups: [(Option<u8>, usize); 6] = [
74        (None, 4),
75        (Some(b'-'), 2),
76        (Some(b'-'), 2),
77        (Some(b'T'), 2),
78        (Some(b':'), 2),
79        (Some(b':'), 2),
80    ];
81    for (position, (separator, width)) in groups.iter().enumerate() {
82        if index >= bytes.len() {
83            break;
84        }
85        if let Some(separator) = separator {
86            if bytes[index] != *separator {
87                if position == 0 {
88                    return None;
89                }
90                break;
91            }
92            index += 1;
93        }
94        let digits = text.get(index..index + width)?;
95        if !digits.bytes().all(|b| b.is_ascii_digit()) {
96            return None;
97        }
98        out.push_str(digits);
99        index += width;
100    }
101    if index < bytes.len() && bytes[index] == b'.' {
102        let start = index + 1;
103        let mut end = start;
104        while end < bytes.len() && bytes[end].is_ascii_digit() {
105            end += 1;
106        }
107        if end == start || out.len() != 14 {
108            return None;
109        }
110        out.push('.');
111        out.push_str(&text[start..end]);
112        index = end;
113    }
114    let rest = &text[index..];
115    match rest {
116        "" => {}
117        "Z" => out.push_str("+0000"),
118        _ => {
119            let (sign, zone) = rest.split_at(1);
120            if !(sign == "+" || sign == "-") {
121                return None;
122            }
123            let (hours, minutes) = zone.split_once(':')?;
124            if hours.len() != 2
125                || minutes.len() != 2
126                || !hours
127                    .bytes()
128                    .chain(minutes.bytes())
129                    .all(|b| b.is_ascii_digit())
130            {
131                return None;
132            }
133            out.push_str(sign);
134            out.push_str(hours);
135            out.push_str(minutes);
136        }
137    }
138    Some(out)
139}
140
141type Zone<'a> = Option<(char, &'a str, &'a str)>;
142
143/// Splits a trailing `+ZZzz` / `-ZZzz` zone off a compact timestamp.
144fn split_zone(text: &str) -> Option<(&str, Zone<'_>)> {
145    match text.rfind(['+', '-']) {
146        Some(at) => {
147            let zone = &text[at + 1..];
148            if zone.len() != 4 || !zone.bytes().all(|b| b.is_ascii_digit()) {
149                return None;
150            }
151            let sign = text[at..].chars().next()?;
152            Some((&text[..at], Some((sign, &zone[0..2], &zone[2..4]))))
153        }
154        None => Some((text, None)),
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn keeps_the_precision_both_ways() {
164        for (compact, fhir) in [
165            ("2020", "2020"),
166            ("202001", "2020-01"),
167            ("20200115", "2020-01-15"),
168            ("2020011509", "2020-01-15T09"),
169            ("202001150930", "2020-01-15T09:30"),
170            ("20200115093005", "2020-01-15T09:30:05"),
171            ("20200115093005.1234", "2020-01-15T09:30:05.1234"),
172            ("20200115093005-0500", "2020-01-15T09:30:05-05:00"),
173            ("202001150930+0130", "2020-01-15T09:30+01:30"),
174            ("20200115-0500", "2020-01-15-05:00"),
175        ] {
176            assert_eq!(from_compact(compact).as_deref(), Some(fhir), "{compact}");
177            assert_eq!(to_compact(fhir).as_deref(), Some(compact), "{fhir}");
178        }
179        assert_eq!(
180            to_compact("2020-01-15T09:30:05Z").as_deref(),
181            Some("20200115093005+0000")
182        );
183    }
184
185    #[test]
186    fn rejects_what_is_not_a_timestamp() {
187        for text in [
188            "",
189            "20",
190            "2020011",
191            "2020-01",
192            "abcd",
193            "20200115.5",
194            "2020+05",
195        ] {
196            assert_eq!(from_compact(text), None, "{text}");
197        }
198        for text in ["", "2020/01", "2020-1", "2020-01-15T09:30:05+0500", "x"] {
199            assert_eq!(to_compact(text), None, "{text}");
200        }
201    }
202}