1use crate::error::{DbError, Result};
25use std::time::{Duration, SystemTime};
26
27pub const TIMESTAMP_LEN: usize = 27;
29
30pub const OPEN_SENTINEL: &str = "9999-12-31T23:59:59.999999Z";
38
39pub const CANONICAL_TS_GLOB: &str =
45 "'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]Z'";
46
47pub fn is_canonical(s: &str) -> bool {
52 let b = s.as_bytes();
53 if b.len() != TIMESTAMP_LEN {
54 return false;
55 }
56 const DIGITS: [usize; 20] = [
57 0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 21, 22, 23, 24, 25,
58 ];
59 const SEPS: [(usize, u8); 7] = [
60 (4, b'-'),
61 (7, b'-'),
62 (10, b'T'),
63 (13, b':'),
64 (16, b':'),
65 (19, b'.'),
66 (26, b'Z'),
67 ];
68 DIGITS.iter().all(|&i| b[i].is_ascii_digit()) && SEPS.iter().all(|&(i, c)| b[i] == c)
69}
70
71pub fn normalize(s: &str) -> Result<String> {
79 if is_canonical(s) {
80 return Ok(s.to_string());
81 }
82 if s.len() == 20 && s.ends_with('Z') {
84 let widened = format!("{}.000000Z", &s[..19]);
85 if is_canonical(&widened) {
86 return Ok(widened);
87 }
88 }
89 Err(DbError::InvalidTimestamp {
90 value: s.to_string(),
91 reason: "expected YYYY-MM-DDTHH:MM:SS.ffffffZ".to_string(),
92 })
93}
94
95fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
100 let y = y - if m <= 2 { 1 } else { 0 };
101 let era = if y >= 0 { y } else { y - 399 } / 400;
102 let yoe = y - era * 400; let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468
106}
107
108fn civil_from_days(z: i64) -> (i64, i64, i64) {
110 let z = z + 719468;
111 let era = if z >= 0 { z } else { z - 146096 } / 146097;
112 let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
115 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; (y + if m <= 2 { 1 } else { 0 }, m, d)
120}
121
122fn days_in_month(year: i64, month: i64) -> i64 {
124 match month {
125 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
126 4 | 6 | 9 | 11 => 30,
127 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
128 2 => 28,
129 _ => 0,
130 }
131}
132
133pub fn parse(s: &str) -> Result<SystemTime> {
140 let canon = normalize(s)?;
141 let b = canon.as_bytes();
142 let num = |lo: usize, hi: usize| -> i64 {
143 canon[lo..hi]
144 .parse::<i64>()
145 .expect("is_canonical checked digits")
146 };
147 let (year, month, day) = (num(0, 4), num(5, 7), num(8, 10));
148 let (hour, min, sec) = (num(11, 13), num(14, 16), num(17, 19));
149 let micros = num(20, 26);
150 debug_assert_eq!(b[26], b'Z');
151
152 let bad = |why: &str| DbError::InvalidTimestamp {
153 value: s.to_string(),
154 reason: why.to_string(),
155 };
156 if !(1..=12).contains(&month) {
157 return Err(bad("month out of range"));
158 }
159 if day < 1 || day > days_in_month(year, month) {
160 return Err(bad("day out of range for month"));
161 }
162 if hour > 23 || min > 59 || sec > 59 {
166 return Err(bad("time component out of range"));
167 }
168
169 let secs = days_from_civil(year, month, day) * 86_400 + hour * 3600 + min * 60 + sec;
170 let offset = Duration::new(secs.unsigned_abs(), micros as u32 * 1_000);
171 let t = if secs >= 0 {
172 SystemTime::UNIX_EPOCH.checked_add(offset)
173 } else {
174 SystemTime::UNIX_EPOCH.checked_sub(offset)
175 };
176 t.ok_or_else(|| bad("not representable as a SystemTime on this platform"))
177}
178
179pub fn format(st: SystemTime) -> String {
184 let d = st
185 .duration_since(SystemTime::UNIX_EPOCH)
186 .unwrap_or_default();
187 let secs = d.as_secs() as i64;
188 let micros = d.subsec_micros();
189
190 let days = secs.div_euclid(86_400);
192 let tod = secs.rem_euclid(86_400);
193 let (y, m, dd) = civil_from_days(days);
194
195 format!(
196 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
197 y,
198 m,
199 dd,
200 tod / 3600,
201 (tod % 3600) / 60,
202 tod % 60,
203 micros
204 )
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn sentinel_is_canonical_and_maximal() {
213 assert!(is_canonical(OPEN_SENTINEL));
214 assert_eq!(OPEN_SENTINEL.len(), TIMESTAMP_LEN);
215 assert!("2026-01-01T00:00:00.000000Z" < OPEN_SENTINEL);
218 assert!("9999-12-31T23:59:59.999998Z" < OPEN_SENTINEL);
219 }
220
221 #[test]
222 fn canonical_form_orders_lexicographically() {
223 let a = normalize("2026-01-01T00:00:00Z").unwrap();
226 let b = "2026-01-01T00:00:00.000000Z";
227 assert_eq!(a, b);
228 assert!(a.as_str() <= b);
229
230 let mut stamps = [
231 "2026-01-01T00:00:01.000000Z",
232 "2026-01-01T00:00:00.000001Z",
233 "2026-01-01T00:00:00.000000Z",
234 "2025-12-31T23:59:59.999999Z",
235 ];
236 stamps.sort_unstable();
237 assert_eq!(stamps[0], "2025-12-31T23:59:59.999999Z");
238 assert_eq!(stamps[3], "2026-01-01T00:00:01.000000Z");
239 }
240
241 #[test]
242 fn normalize_widens_seconds_and_rejects_everything_else() {
243 assert_eq!(
244 normalize("2026-01-01T00:00:00Z").unwrap(),
245 "2026-01-01T00:00:00.000000Z"
246 );
247 assert_eq!(normalize(OPEN_SENTINEL).unwrap(), OPEN_SENTINEL);
248
249 for bad in [
250 "2026-01-01T00:00:00", "2026-01-01T00:00:00+01:00", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000000000Z", "2026-01-01 00:00:00.000000Z", "",
256 ] {
257 assert!(normalize(bad).is_err(), "should reject {bad:?}");
258 }
259 }
260
261 #[test]
262 fn format_reports_the_real_date() {
263 assert_eq!(
266 format(SystemTime::UNIX_EPOCH),
267 "1970-01-01T00:00:00.000000Z"
268 );
269 assert_eq!(
270 format(SystemTime::UNIX_EPOCH + Duration::from_secs(86_400)),
271 "1970-01-02T00:00:00.000000Z"
272 );
273 assert_eq!(
275 format(SystemTime::UNIX_EPOCH + Duration::from_secs(951_868_800)),
276 "2000-03-01T00:00:00.000000Z"
277 );
278 }
279
280 #[test]
281 fn parse_format_roundtrip() {
282 for s in [
283 "1970-01-01T00:00:00.000000Z",
284 "2000-02-29T12:34:56.654321Z",
285 "2026-07-28T09:15:00.000001Z",
286 OPEN_SENTINEL,
287 ] {
288 assert_eq!(format(parse(s).unwrap()), s, "roundtrip failed for {s}");
289 }
290 }
291
292 #[test]
293 fn parse_validates_the_calendar_not_just_the_shape() {
294 for bad in [
295 "2026-02-30T00:00:00.000000Z", "2026-13-01T00:00:00.000000Z", "2026-00-01T00:00:00.000000Z",
298 "2025-02-29T00:00:00.000000Z", "2026-01-01T24:00:00.000000Z",
300 "2026-01-01T00:60:00.000000Z",
301 "2026-01-01T00:00:60.000000Z", ] {
303 assert!(parse(bad).is_err(), "should reject {bad:?}");
304 }
305 assert!(parse("2024-02-29T00:00:00.000000Z").is_ok());
307 }
308}