Skip to main content

zeph_common/
timestamp.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! UTC timestamp helpers shared across the workspace.
5//!
6//! All functions use [`std::time::SystemTime`] — no external date/time crates required.
7//! The O(1) Hinnant algorithm is used internally to convert Unix seconds to a
8//! calendar date without iterating over years or months.
9
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Converts seconds since the Unix epoch to `(year, month, day, hour, minute, second)` UTC.
13///
14/// Uses the proleptic-Gregorian Hinnant algorithm — O(1), no heap allocation.
15/// Kept `pub(crate)` because the raw 6-tuple is an implementation detail; prefer
16/// the higher-level functions in this module for public use.
17pub(crate) const fn secs_to_ymdhms(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
18    const SECS_PER_MIN: u64 = 60;
19    const DAYS_PER_400Y: u64 = 146_097;
20
21    let s = (secs % SECS_PER_MIN) as u32;
22    let total_mins = secs / SECS_PER_MIN;
23    let mi = (total_mins % 60) as u32;
24    let total_hours = total_mins / 60;
25    let h = (total_hours % 24) as u32;
26    let mut days = total_hours / 24;
27
28    // Shift epoch from 1970-01-01 to 0000-03-01 for Gregorian math.
29    days += 719_468;
30    let era = days / DAYS_PER_400Y;
31    let doe = days % DAYS_PER_400Y;
32    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
33    let y = yoe + era * 400;
34    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
35    let mp = (5 * doy + 2) / 153;
36    // All intermediate values are calendar-bounded (day ≤ 31, month ≤ 12, year ≤ ~u32::MAX/2).
37    #[allow(clippy::cast_possible_truncation)]
38    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
39    #[allow(clippy::cast_possible_truncation)]
40    let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
41    #[allow(clippy::cast_possible_truncation)]
42    let y = if mo <= 2 { y + 1 } else { y } as u32;
43    (y, mo, d, h, mi, s)
44}
45
46fn unix_secs() -> u64 {
47    SystemTime::now()
48        .duration_since(UNIX_EPOCH)
49        .map_or(0, |d| d.as_secs())
50}
51
52/// Returns the current UTC time as a `(year, month, day, hour, minute, second)` tuple.
53///
54/// # Examples
55///
56/// ```rust
57/// let (y, mo, d, h, mi, s) = zeph_common::timestamp::utc_now_datetime();
58/// assert!(y >= 2024, "year should be at least 2024");
59/// assert!((1..=12).contains(&mo));
60/// assert!((1..=31).contains(&d));
61/// assert!(h < 24 && mi < 60 && s < 60);
62/// ```
63#[must_use]
64pub fn utc_now_datetime() -> (u32, u32, u32, u32, u32, u32) {
65    secs_to_ymdhms(unix_secs())
66}
67
68/// Returns the current UTC time in RFC 3339 format: `YYYY-MM-DDTHH:MM:SSZ`.
69///
70/// # Examples
71///
72/// ```rust
73/// let ts = zeph_common::timestamp::utc_now_rfc3339();
74/// assert_eq!(ts.len(), 20, "expected format YYYY-MM-DDTHH:MM:SSZ");
75/// assert!(ts.ends_with('Z'));
76/// assert_eq!(&ts[10..11], "T");
77/// ```
78#[must_use]
79pub fn utc_now_rfc3339() -> String {
80    let (y, mo, d, h, mi, s) = utc_now_datetime();
81    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
82}
83
84/// Returns the current UTC time in compact format: `YYYYMMDD_HHMMSS`.
85///
86/// Suitable for filenames and run identifiers where colons and hyphens are undesirable.
87///
88/// # Examples
89///
90/// ```rust
91/// let ts = zeph_common::timestamp::utc_now_compact();
92/// assert_eq!(ts.len(), 15, "expected format YYYYMMDD_HHMMSS");
93/// assert_eq!(&ts[8..9], "_");
94/// ```
95#[must_use]
96pub fn utc_now_compact() -> String {
97    let (y, mo, d, h, mi, s) = utc_now_datetime();
98    format!("{y:04}{mo:02}{d:02}_{h:02}{mi:02}{s:02}")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn rfc3339_format_is_correct() {
107        let ts = utc_now_rfc3339();
108        assert_eq!(ts.len(), 20);
109        assert!(ts.ends_with('Z'));
110        assert_eq!(&ts[10..11], "T");
111        // Basic digit checks
112        assert!(ts[..4].chars().all(|c| c.is_ascii_digit()));
113    }
114
115    #[test]
116    fn compact_format_is_correct() {
117        let ts = utc_now_compact();
118        assert_eq!(ts.len(), 15);
119        assert_eq!(&ts[8..9], "_");
120    }
121
122    #[test]
123    fn secs_to_ymdhms_known_epoch() {
124        // Unix epoch = 1970-01-01 00:00:00
125        let (y, mo, d, h, mi, s) = secs_to_ymdhms(0);
126        assert_eq!((y, mo, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
127    }
128
129    #[test]
130    fn secs_to_ymdhms_known_date() {
131        // 2024-03-01 00:00:00 UTC = 1709251200
132        let (y, mo, d, h, mi, s) = secs_to_ymdhms(1_709_251_200);
133        assert_eq!((y, mo, d, h, mi, s), (2024, 3, 1, 0, 0, 0));
134    }
135
136    #[test]
137    fn secs_to_ymdhms_y2k_boundary() {
138        assert_eq!(secs_to_ymdhms(946_684_800), (2000, 1, 1, 0, 0, 0));
139    }
140
141    #[test]
142    fn secs_to_ymdhms_feb29_century_leap_year() {
143        assert_eq!(secs_to_ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0));
144    }
145
146    #[test]
147    fn secs_to_ymdhms_feb29_regular_leap_year() {
148        assert_eq!(secs_to_ymdhms(1_709_164_800), (2024, 2, 29, 0, 0, 0));
149    }
150
151    #[test]
152    fn secs_to_ymdhms_year_end_boundary() {
153        assert_eq!(secs_to_ymdhms(1_704_067_199), (2023, 12, 31, 23, 59, 59));
154    }
155
156    #[test]
157    fn secs_to_ymdhms_nontrivial_hhmmss() {
158        assert_eq!(secs_to_ymdhms(1_718_458_245), (2024, 6, 15, 13, 30, 45));
159    }
160
161    #[test]
162    fn datetime_fields_are_in_range() {
163        let (y, mo, d, h, mi, s) = utc_now_datetime();
164        assert!(y >= 2024);
165        assert!((1..=12).contains(&mo));
166        assert!((1..=31).contains(&d));
167        assert!(h < 24 && mi < 60 && s < 60);
168    }
169}