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 if bytes.len() < 20 {
46 return Err("too short");
47 }
48 if !is_ymd(&bytes[..10]) {
49 return Err("invalid date");
50 }
51 if bytes[10] != b'T' {
52 return Err("missing 'T' separator");
53 }
54 if !is_hms(&bytes[11..19]) {
55 return Err("invalid time");
56 }
57 let mut i = 19usize;
59 if bytes.get(i) == Some(&b'.') {
60 i += 1;
61 let start = i;
62 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
63 i += 1;
64 }
65 if i == start {
66 return Err("empty fractional seconds");
67 }
68 }
69 match bytes.get(i) {
70 Some(&b'Z') if i + 1 == bytes.len() => Ok(()),
71 Some(&b'+' | &b'-') if bytes.len() - i == 6 => {
72 if is_offset(&bytes[i + 1..]) {
73 Ok(())
74 } else {
75 Err("invalid timezone offset")
76 }
77 }
78 _ => Err("missing timezone designator"),
79 }
80}
81
82fn is_ymd(b: &[u8]) -> bool {
83 b.len() == 10
84 && b[..4].iter().all(u8::is_ascii_digit)
85 && b[4] == b'-'
86 && b[5..7].iter().all(u8::is_ascii_digit)
87 && b[7] == b'-'
88 && b[8..10].iter().all(u8::is_ascii_digit)
89 && in_range(&b[5..7], 1, 12)
90 && in_range(&b[8..10], 1, 31)
91}
92
93fn is_hms(b: &[u8]) -> bool {
94 b.len() == 8
95 && b[..2].iter().all(u8::is_ascii_digit)
96 && b[2] == b':'
97 && b[3..5].iter().all(u8::is_ascii_digit)
98 && b[5] == b':'
99 && b[6..8].iter().all(u8::is_ascii_digit)
100 && in_range(&b[..2], 0, 23)
101 && in_range(&b[3..5], 0, 59)
102 && in_range(&b[6..8], 0, 60) }
104
105fn is_offset(b: &[u8]) -> bool {
106 b.len() == 5
107 && b[..2].iter().all(u8::is_ascii_digit)
108 && b[2] == b':'
109 && b[3..5].iter().all(u8::is_ascii_digit)
110 && in_range(&b[..2], 0, 23)
111 && in_range(&b[3..5], 0, 59)
112}
113
114fn in_range(b: &[u8], lo: u32, hi: u32) -> bool {
115 std::str::from_utf8(b)
116 .ok()
117 .and_then(|s| s.parse::<u32>().ok())
118 .is_some_and(|n| (lo..=hi).contains(&n))
119}
120
121fn format_epoch(seconds: u64, nanos: u32) -> String {
126 let days = i64::try_from(seconds / 86_400).unwrap_or(0);
127 let sod = seconds % 86_400;
128 let hour = (sod / 3600) as u32;
129 let minute = ((sod % 3600) / 60) as u32;
130 let second = (sod % 60) as u32;
131 let (year, month, day) = civil_from_days(days);
132 format!(
133 "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z"
134 )
135}
136
137#[allow(
141 clippy::cast_possible_truncation,
142 clippy::cast_possible_wrap,
143 clippy::cast_sign_loss
144)]
145const fn civil_from_days(days: i64) -> (i32, u32, u32) {
146 let z = days + 719_468;
147 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
148 let doe = (z - era * 146_097) as u64;
149 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
150 let y = yoe as i64 + era * 400;
151 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
152 let mp = (5 * doy + 2) / 153;
153 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
154 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
155 let year_offset: i64 = if m <= 2 { 1 } else { 0 };
156 ((y + year_offset) as i32, m, d)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn now_iso8601_shape() {
165 let s = now_iso8601();
166 assert_eq!(s.len(), 30, "{s}"); assert!(s.ends_with('Z'));
168 assert_eq!(&s[4..5], "-");
169 assert_eq!(&s[10..11], "T");
170 assert!(parse_iso8601(&s).is_ok(), "roundtrip failed for {s}");
171 }
172
173 #[test]
174 fn parses_canonical_utc() {
175 assert!(parse_iso8601("2024-08-29T12:00:00Z").is_ok());
176 assert!(
177 parse_iso8601("1970-01-01T00:00:00.000000000Z").is_ok()
178 );
179 assert!(parse_iso8601("2099-12-31T23:59:59.123Z").is_ok());
180 }
181
182 #[test]
183 fn parses_offset_zones() {
184 assert!(parse_iso8601("2024-08-29T12:00:00+02:00").is_ok());
185 assert!(parse_iso8601("2024-08-29T12:00:00.5-05:30").is_ok());
186 }
187
188 #[test]
189 fn rejects_garbage() {
190 assert!(parse_iso8601("").is_err());
191 assert!(parse_iso8601("not a date").is_err());
192 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()); }
198
199 #[test]
200 fn known_epoch_values() {
201 assert_eq!(
202 format_epoch(0, 0),
203 "1970-01-01T00:00:00.000000000Z"
204 );
205 assert_eq!(
207 format_epoch(1_700_000_000, 0),
208 "2023-11-14T22:13:20.000000000Z"
209 );
210 }
211
212 #[test]
213 fn rejects_dangling_fractional_dot() {
214 let r = parse_iso8601("2024-08-29T12:00:00.Z");
216 let msg = r.unwrap_err().to_string();
217 assert!(msg.contains("empty fractional seconds"), "got: {msg}");
218 }
219
220 #[test]
221 fn rejects_invalid_timezone_offset() {
222 let r = parse_iso8601("2024-08-29T12:00:00+99:99");
224 let msg = r.unwrap_err().to_string();
225 assert!(msg.contains("invalid timezone offset"), "got: {msg}");
226 }
227
228 #[test]
229 fn rejects_missing_timezone_designator() {
230 let r = parse_iso8601("2024-08-29T12:00:00X");
232 let msg = r.unwrap_err().to_string();
233 assert!(
234 msg.contains("missing timezone designator"),
235 "got: {msg}"
236 );
237 }
238
239 #[test]
240 fn civil_from_days_handles_pre_epoch() {
241 let (y, m, d) = civil_from_days(-719_469);
247 assert_eq!((y, m, d), (0, 2, 29));
248
249 let (y2, _, _) = civil_from_days(-1_000_000);
251 assert!(y2 < 0);
252 }
253}