1const DAY: u64 = 86_400;
25
26const MICROS: u64 = 1_000_000;
28
29pub fn compact(micros_since_epoch: u64) -> String {
39 let seconds = micros_since_epoch / MICROS;
40 let fraction = micros_since_epoch % MICROS;
41 let (year, month, day, hour, minute, second) = civil(seconds);
42 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{fraction:06}Z")
43}
44
45pub fn expanded(seconds_since_epoch: u64) -> String {
55 let (year, month, day, hour, minute, second) = civil(seconds_since_epoch);
56 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} GMT")
57}
58
59pub fn from_expanded(text: &str) -> Option<u64> {
70 let body = text.strip_suffix(" GMT")?;
71 let (date, clock) = body.split_once(' ')?;
72 from_compact(&format!("{date}T{clock}Z")).map(|micros| micros / MICROS)
73}
74
75pub fn from_compact(text: &str) -> Option<u64> {
89 let body = text.strip_suffix('Z')?;
90 let (date, rest) = body.split_once('T')?;
91 let (clock, fraction) = match rest.split_once('.') {
92 Some((clock, digits)) => (clock, micros_of(digits)?),
93 None => (rest, 0),
94 };
95
96 let mut parts = date.split('-');
97 let year: i64 = parts.next()?.parse().ok()?;
98 let month: u32 = parts.next()?.parse().ok()?;
99 let day: u32 = parts.next()?.parse().ok()?;
100 if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
101 return None;
102 }
103
104 let mut clock = clock.split(':');
105 let hour: u64 = clock.next()?.parse().ok()?;
106 let minute: u64 = clock.next()?.parse().ok()?;
107 let second: u64 = clock.next()?.parse().ok()?;
108 if clock.next().is_some() || hour > 23 || minute > 59 || second > 60 {
109 return None;
110 }
111
112 let days = days_from_civil(year, month, day);
113 if days < 0 {
114 return None;
115 }
116 let seconds = u64::try_from(days).ok()? * DAY + hour * 3_600 + minute * 60 + second;
117 Some(seconds * MICROS + fraction)
118}
119
120fn micros_of(digits: &str) -> Option<u64> {
122 if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
123 return None;
124 }
125 let value: u64 = digits.parse().ok()?;
126 Some(value * 10u64.pow(6 - digits.len() as u32))
127}
128
129fn civil(seconds_since_epoch: u64) -> (i64, u32, u32, u64, u64, u64) {
131 let days = (seconds_since_epoch / DAY) as i64;
132 let rest = seconds_since_epoch % DAY;
133 let (year, month, day) = civil_from_days(days);
134 (year, month, day, rest / 3_600, (rest / 60) % 60, rest % 60)
135}
136
137fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
139 let year = year - i64::from(month <= 2);
140 let era = year.div_euclid(400);
141 let year_of_era = year - era * 400;
142 let day_of_year =
143 (153 * (i64::from(month) + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
144 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
145 era * 146_097 + day_of_era - 719_468
146}
147
148fn civil_from_days(days: i64) -> (i64, u32, u32) {
150 let days = days + 719_468;
151 let era = days.div_euclid(146_097);
152 let day_of_era = days - era * 146_097;
153 let year_of_era =
154 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
155 let year = year_of_era + era * 400;
156 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
157 let shifted = (5 * day_of_year + 2) / 153;
158 let day = (day_of_year - (153 * shifted + 2) / 5 + 1) as u32;
159 let month = (shifted + if shifted < 10 { 3 } else { -9 }) as u32;
160 (year + i64::from(month <= 2), month, day)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn the_protocols_own_examples_format_and_parse() {
169 assert_eq!(
171 compact(1_364_746_877_528_002),
172 "2013-03-31T16:21:17.528002Z"
173 );
174 assert_eq!(
175 from_compact("2013-03-31T16:21:17.528002Z"),
176 Some(1_364_746_877_528_002)
177 );
178 assert_eq!(expanded(1_389_517_168), "2014-01-12 08:59:28 GMT");
179 assert_eq!(
180 from_expanded("2014-01-12 08:59:28 GMT"),
181 Some(1_389_517_168)
182 );
183 assert_eq!(from_expanded("2014-01-12T08:59:28Z"), None);
184 }
185
186 #[test]
187 fn the_calendar_holds_at_its_edges() {
188 assert_eq!(compact(0), "1970-01-01T00:00:00.000000Z");
189 assert_eq!(compact(951_825_600_000_000), "2000-02-29T12:00:00.000000Z");
191 assert_eq!(
192 compact(1_735_689_599_999_999),
193 "2024-12-31T23:59:59.999999Z"
194 );
195 assert_eq!(
197 compact(4_107_542_400_000_000),
198 "2100-03-01T00:00:00.000000Z"
199 );
200 }
201
202 #[test]
203 fn a_shorter_fraction_is_read_as_written() {
204 assert_eq!(
205 from_compact("2013-03-31T16:21:17.528Z"),
206 Some(1_364_746_877_528_000)
207 );
208 assert_eq!(
209 from_compact("2013-03-31T16:21:17Z"),
210 Some(1_364_746_877_000_000)
211 );
212 }
213
214 #[test]
215 fn every_timestamp_survives_a_round_trip() {
216 let mut micros = 0u64;
217 while micros < 4_200_000_000_000_000 {
218 let text = compact(micros);
219 assert_eq!(from_compact(&text), Some(micros), "{text}");
220 micros += 97_777_777_777;
221 }
222 }
223
224 #[test]
225 fn what_is_not_a_timestamp_is_refused() {
226 for text in [
227 "2013-03-31T16:21:17.528002",
228 "2013-03-31 16:21:17.528002Z",
229 "2013-13-31T16:21:17.528002Z",
230 "2013-03-31T24:21:17.528002Z",
231 "2013-03-31T16:21:17.5280021Z",
232 "1969-12-31T23:59:59.000000Z",
233 "not a time",
234 ] {
235 assert_eq!(from_compact(text), None, "{text}");
236 }
237 }
238}