1use crate::error::{RlgError, RlgResult};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15#[must_use]
21pub fn now_iso8601() -> String {
22 let dur = SystemTime::now()
23 .duration_since(UNIX_EPOCH)
24 .unwrap_or_default();
25 format_epoch(dur.as_secs(), dur.subsec_nanos())
26}
27
28pub fn parse_iso8601(s: &str) -> RlgResult<String> {
38 validate(s).map(|()| s.to_string()).map_err(|why| {
39 RlgError::DateTimeParseError(format!("{s:?}: {why}"))
40 })
41}
42
43fn validate(s: &str) -> Result<(), &'static str> {
44 let bytes = s.as_bytes();
45 validate_date_time(bytes)?;
46 let zone = skip_fraction(bytes, 19)?;
48 validate_zone(bytes, zone)
49}
50
51fn validate_date_time(bytes: &[u8]) -> Result<(), &'static str> {
53 if bytes.len() < 20 {
54 return Err("too short");
55 }
56 if !is_ymd(&bytes[..10]) {
57 return Err("invalid date");
58 }
59 if bytes[10] != b'T' {
60 return Err("missing 'T' separator");
61 }
62 if !is_hms(&bytes[11..19]) {
63 return Err("invalid time");
64 }
65 Ok(())
66}
67
68fn skip_fraction(
70 bytes: &[u8],
71 i: usize,
72) -> Result<usize, &'static str> {
73 if bytes.get(i) != Some(&b'.') {
74 return Ok(i);
75 }
76 let digits = bytes[i + 1..]
77 .iter()
78 .take_while(|b| b.is_ascii_digit())
79 .count();
80 if digits == 0 {
81 return Err("empty fractional seconds");
82 }
83 Ok(i + 1 + digits)
84}
85
86fn validate_zone(bytes: &[u8], i: usize) -> Result<(), &'static str> {
88 match bytes.get(i) {
89 Some(&b'Z') if i + 1 == bytes.len() => Ok(()),
90 Some(&b'+' | &b'-') if bytes.len() - i == 6 => {
91 if is_offset(&bytes[i + 1..]) {
92 Ok(())
93 } else {
94 Err("invalid timezone offset")
95 }
96 }
97 _ => Err("missing timezone designator"),
98 }
99}
100
101fn is_ymd(b: &[u8]) -> bool {
102 b.len() == 10
103 && b[..4].iter().all(u8::is_ascii_digit)
104 && b[4] == b'-'
105 && b[5..7].iter().all(u8::is_ascii_digit)
106 && b[7] == b'-'
107 && b[8..10].iter().all(u8::is_ascii_digit)
108 && in_range(&b[5..7], 1, 12)
109 && in_range(&b[8..10], 1, 31)
110}
111
112fn is_hms(b: &[u8]) -> bool {
113 b.len() == 8
114 && b[..2].iter().all(u8::is_ascii_digit)
115 && b[2] == b':'
116 && b[3..5].iter().all(u8::is_ascii_digit)
117 && b[5] == b':'
118 && b[6..8].iter().all(u8::is_ascii_digit)
119 && in_range(&b[..2], 0, 23)
120 && in_range(&b[3..5], 0, 59)
121 && in_range(&b[6..8], 0, 60) }
123
124fn is_offset(b: &[u8]) -> bool {
125 b.len() == 5
126 && b[..2].iter().all(u8::is_ascii_digit)
127 && b[2] == b':'
128 && b[3..5].iter().all(u8::is_ascii_digit)
129 && in_range(&b[..2], 0, 23)
130 && in_range(&b[3..5], 0, 59)
131}
132
133fn in_range(b: &[u8], lo: u32, hi: u32) -> bool {
134 std::str::from_utf8(b)
135 .ok()
136 .and_then(|s| s.parse::<u32>().ok())
137 .is_some_and(|n| (lo..=hi).contains(&n))
138}
139
140fn format_epoch(seconds: u64, nanos: u32) -> String {
145 let days = i64::try_from(seconds / 86_400).unwrap_or(0);
146 let (year, month, day) = civil_from_days(days);
147 let sod = seconds % 86_400;
148 let Ok(year) = u32::try_from(year) else {
151 return format_epoch_general(seconds, nanos);
152 };
153 if year > 9999 || nanos > 999_999_999 {
154 return format_epoch_general(seconds, nanos);
155 }
156 let mut buf = *b"0000-00-00T00:00:00.000000000Z";
157 put_digits(&mut buf[0..4], year);
158 put_digits(&mut buf[5..7], month);
159 put_digits(&mut buf[8..10], day);
160 put_digits(&mut buf[11..13], (sod / 3600) as u32);
161 put_digits(&mut buf[14..16], ((sod % 3600) / 60) as u32);
162 put_digits(&mut buf[17..19], (sod % 60) as u32);
163 put_digits(&mut buf[20..29], nanos);
164 String::from_utf8_lossy(&buf).into_owned()
165}
166
167fn put_digits(dst: &mut [u8], mut n: u32) {
169 for byte in dst.iter_mut().rev() {
170 *byte = b'0' + (n % 10) as u8;
171 n /= 10;
172 }
173}
174
175fn format_epoch_general(seconds: u64, nanos: u32) -> String {
177 let days = i64::try_from(seconds / 86_400).unwrap_or(0);
178 let sod = seconds % 86_400;
179 let hour = (sod / 3600) as u32;
180 let minute = ((sod % 3600) / 60) as u32;
181 let second = (sod % 60) as u32;
182 let (year, month, day) = civil_from_days(days);
183 format!(
184 "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z"
185 )
186}
187
188#[allow(
192 clippy::cast_possible_truncation,
193 clippy::cast_possible_wrap,
194 clippy::cast_sign_loss
195)]
196const fn civil_from_days(days: i64) -> (i32, u32, u32) {
197 let z = days + 719_468;
198 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
199 let doe = (z - era * 146_097) as u64;
200 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
201 let y = yoe as i64 + era * 400;
202 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
203 let mp = (5 * doy + 2) / 153;
204 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
205 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
206 let year_offset: i64 = if m <= 2 { 1 } else { 0 };
207 ((y + year_offset) as i32, m, d)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn format_epoch_known_instants() {
216 let cases = [
217 (0, 0, "1970-01-01T00:00:00.000000000Z"),
218 (951_782_400, 5, "2000-02-29T00:00:00.000000005Z"),
219 (
220 1_727_740_800,
221 123_456_789,
222 "2024-10-01T00:00:00.123456789Z",
223 ),
224 (
225 253_402_300_799,
226 999_999_999,
227 "9999-12-31T23:59:59.999999999Z",
228 ),
229 (253_402_300_800, 0, "10000-01-01T00:00:00.000000000Z"),
231 ];
232 for (secs, nanos, want) in cases {
233 assert_eq!(format_epoch(secs, nanos), want, "{secs}");
234 }
235 }
236
237 #[test]
238 fn now_iso8601_shape() {
239 let s = now_iso8601();
240 assert_eq!(s.len(), 30, "{s}"); assert!(s.ends_with('Z'));
242 assert_eq!(&s[4..5], "-");
243 assert_eq!(&s[10..11], "T");
244 assert!(parse_iso8601(&s).is_ok(), "roundtrip failed for {s}");
245 }
246
247 #[test]
248 fn parses_canonical_utc() {
249 assert!(parse_iso8601("2024-08-29T12:00:00Z").is_ok());
250 assert!(
251 parse_iso8601("1970-01-01T00:00:00.000000000Z").is_ok()
252 );
253 assert!(parse_iso8601("2099-12-31T23:59:59.123Z").is_ok());
254 }
255
256 #[test]
257 fn parses_offset_zones() {
258 assert!(parse_iso8601("2024-08-29T12:00:00+02:00").is_ok());
259 assert!(parse_iso8601("2024-08-29T12:00:00.5-05:30").is_ok());
260 }
261
262 #[test]
263 fn rejects_garbage() {
264 assert!(parse_iso8601("").is_err());
265 assert!(parse_iso8601("not a date").is_err());
266 assert!(parse_iso8601("2024-13-29T12:00:00Z").is_err()); assert!(parse_iso8601("2024-08-32T12:00:00Z").is_err()); assert!(parse_iso8601("2024-08-29T24:00:00Z").is_err()); assert!(parse_iso8601("2024-08-29T12:00:00").is_err()); assert!(parse_iso8601("2024-08-29 12:00:00Z").is_err()); }
272
273 #[test]
274 fn known_epoch_values() {
275 assert_eq!(
276 format_epoch(0, 0),
277 "1970-01-01T00:00:00.000000000Z"
278 );
279 assert_eq!(
281 format_epoch(1_700_000_000, 0),
282 "2023-11-14T22:13:20.000000000Z"
283 );
284 }
285
286 #[test]
287 fn rejects_dangling_fractional_dot() {
288 let r = parse_iso8601("2024-08-29T12:00:00.Z");
290 let msg = r.unwrap_err().to_string();
291 assert!(msg.contains("empty fractional seconds"), "got: {msg}");
292 }
293
294 #[test]
295 fn rejects_invalid_timezone_offset() {
296 let r = parse_iso8601("2024-08-29T12:00:00+99:99");
298 let msg = r.unwrap_err().to_string();
299 assert!(msg.contains("invalid timezone offset"), "got: {msg}");
300 }
301
302 #[test]
303 fn rejects_missing_timezone_designator() {
304 let r = parse_iso8601("2024-08-29T12:00:00X");
306 let msg = r.unwrap_err().to_string();
307 assert!(
308 msg.contains("missing timezone designator"),
309 "got: {msg}"
310 );
311 }
312
313 #[test]
314 fn civil_from_days_handles_pre_epoch() {
315 let (y, m, d) = civil_from_days(-719_469);
321 assert_eq!((y, m, d), (0, 2, 29));
322
323 let (y2, _, _) = civil_from_days(-1_000_000);
325 assert!(y2 < 0);
326 }
327}