1pub 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
63pub 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 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
143fn 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}