Skip to main content

rlg/
datetime.rs

1// datetime.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Minimal ISO 8601 timestamp generation and validation.
7//!
8//! Uses only `std::time` — no external date crates. Replaces the historical
9//! `dtt` dependency (which transitively pulled the unmaintained `paste`
10//! crate, RUSTSEC-2024-0436).
11
12use crate::error::{RlgError, RlgResult};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15/// Return the current UTC timestamp in RFC 3339 / ISO 8601 form with
16/// nanosecond precision: `YYYY-MM-DDTHH:MM:SS.fffffffffZ`.
17///
18/// Falls back to `1970-01-01T00:00:00.000000000Z` if the system clock
19/// is set before `UNIX_EPOCH`.
20#[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
28/// Validate `s` as an RFC 3339 / ISO 8601 timestamp.
29///
30/// Accepts the subset this crate emits: `YYYY-MM-DDTHH:MM:SS[.fff…]Z`
31/// or `YYYY-MM-DDTHH:MM:SS[.fff…][+HH:MM|-HH:MM]`. Returns the original
32/// input on success.
33///
34/// # Errors
35/// Returns [`RlgError::DateTimeParseError`] when the string does not
36/// match the supported grammar or contains out-of-range components.
37pub 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    // Optional fractional seconds, then mandatory zone designator.
47    let zone = skip_fraction(bytes, 19)?;
48    validate_zone(bytes, zone)
49}
50
51/// `YYYY-MM-DDTHH:MM:SS`, the fixed-width head every timestamp has.
52fn 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
68/// The index after `.digits` at `i`, or `i` when there is no fraction.
69fn 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
86/// `Z` or `±HH:MM` at `i`, ending the string.
87fn 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) // leap second tolerated
122}
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
140/// Convert `seconds` since `UNIX_EPOCH` (+ `nanos`) into an RFC 3339 string.
141///
142/// Uses Howard Hinnant's `civil_from_days` algorithm — branch-free, no
143/// allocation beyond the final `String`.
144fn 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    // Four-digit years and in-range nanoseconds fill a fixed layout;
149    // anything else keeps the general formatter's output.
150    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
167/// Write `n` into `dst` as zero-padded decimal, filling it right to left.
168fn 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
175/// The general form, for years past 9999.
176fn 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/// Days since 1970-01-01 (UTC) → (year, month [1..=12], day [1..=31]).
189///
190/// Algorithm from <http://howardhinnant.github.io/date_algorithms.html>.
191#[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            // The first five-digit year takes the general path.
230            (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}"); // YYYY-MM-DDTHH:MM:SS.fffffffffZ
241        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()); // bad month
267        assert!(parse_iso8601("2024-08-32T12:00:00Z").is_err()); // bad day
268        assert!(parse_iso8601("2024-08-29T24:00:00Z").is_err()); // bad hour
269        assert!(parse_iso8601("2024-08-29T12:00:00").is_err()); // missing zone
270        assert!(parse_iso8601("2024-08-29 12:00:00Z").is_err()); // missing T
271    }
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        // 1700000000 = 2023-11-14T22:13:20Z
280        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        // Hits the `empty fractional seconds` branch.
289        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        // Hits the `invalid timezone offset` branch (right length, bad digits).
297        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        // Hits the `_` (catch-all) arm in the match — neither Z nor +/-.
305        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        // Exercises the negative-`z` branch in civil_from_days.
316        // The algorithm anchors at days = -719_468 → 0000-03-01, so
317        // -719_469 lands on 0000-02-29 (year 0 is a leap year in
318        // proleptic Gregorian). What matters here is hitting the
319        // `z < 0` arm in `civil_from_days`, not the exact value.
320        let (y, m, d) = civil_from_days(-719_469);
321        assert_eq!((y, m, d), (0, 2, 29));
322
323        // Also exercise a deeply negative input so era != 0.
324        let (y2, _, _) = civil_from_days(-1_000_000);
325        assert!(y2 < 0);
326    }
327}