Skip to main content

av_core/
time.rs

1//! Time helpers.
2//!
3//! Wall-clock time is used for display and record timestamps; ordering
4//! decisions inside a session always use the per-session sequence number, never
5//! the wall clock (clock skew must not corrupt event-chain order — silent-error
6//! class D13.6 in the plan).
7
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// Milliseconds since the Unix epoch. Saturates at 0 if the system clock is
11/// before the epoch (never panics).
12pub fn now_ms() -> u64 {
13    SystemTime::now()
14        .duration_since(UNIX_EPOCH)
15        .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
16        .unwrap_or(0)
17}
18
19/// RFC 3339 / ISO-8601 UTC timestamp with millisecond precision, e.g.
20/// `2026-08-10T17:03:05.123Z`. Hand-rolled civil-from-days conversion so we
21/// avoid a chrono dependency in the core crate.
22pub fn iso8601_ms(epoch_ms: u64) -> String {
23    let secs = epoch_ms / crate::units::MS_PER_SEC;
24    let ms = epoch_ms % crate::units::MS_PER_SEC;
25    let days = secs / crate::units::SECS_PER_DAY;
26    let rem = secs % crate::units::SECS_PER_DAY;
27    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
28    let (year, month, day) = civil_from_days(days as i64);
29    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{ms:03}Z")
30}
31
32/// Current time as ISO-8601 UTC.
33pub fn now_iso8601() -> String {
34    iso8601_ms(now_ms())
35}
36
37/// Elapsed microseconds since `started`, saturating at `u64::MAX` if a caller
38/// somehow measures longer than ~585 000 years.
39pub fn elapsed_us(started: std::time::Instant) -> u64 {
40    u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
41}
42
43/// Howard Hinnant's `civil_from_days` algorithm (public domain).
44fn civil_from_days(z: i64) -> (i64, u32, u32) {
45    let z = z + 719_468;
46    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
47    let doe = z - era * 146_097;
48    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
49    let y = yoe + era * 400;
50    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
51    let mp = (5 * doy + 2) / 153;
52    let d = doy - (153 * mp + 2) / 5 + 1;
53    let m = if mp < 10 { mp + 3 } else { mp - 9 };
54    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
55    (if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
56}
57
58#[cfg(test)]
59mod tests {
60    #![allow(clippy::indexing_slicing)]
61
62    use super::*;
63
64    #[test]
65    fn epoch_zero() {
66        assert_eq!(iso8601_ms(0), "1970-01-01T00:00:00.000Z");
67    }
68
69    #[test]
70    fn known_timestamp() {
71        // 2026-08-10T00:00:00Z == 1786320000000 ms (20675 days × 86400 s)
72        assert_eq!(iso8601_ms(1_786_320_000_000), "2026-08-10T00:00:00.000Z");
73    }
74
75    #[test]
76    fn leap_year_feb_29() {
77        // 2024-02-29T12:34:56.789Z == 1709210096789
78        assert_eq!(iso8601_ms(1_709_210_096_789), "2024-02-29T12:34:56.789Z");
79    }
80
81    #[test]
82    fn year_2038_safe() {
83        // 2100-01-01T00:00:00Z = 4102444800000 ms — u64 ms has centuries of headroom.
84        assert_eq!(iso8601_ms(4_102_444_800_000), "2100-01-01T00:00:00.000Z");
85    }
86
87    #[test]
88    fn now_is_sane() {
89        let now = now_ms();
90        assert!(now > 1_700_000_000_000, "clock reads before Nov 2023: {now}");
91        let iso = now_iso8601();
92        assert!(iso.ends_with('Z') && iso.len() == 24, "bad iso format: {iso}");
93    }
94
95    #[test]
96    fn civil_from_days_exercises_month_and_day_arithmetic() {
97        // Epoch day zero, Mar 1 2024 (day after a leap Feb 29), Dec 31 2099
98        // (last day before the 100-year non-leap boundary), and Feb 29 2000
99        // (400-year leap). Each hits a different branch of civil_from_days;
100        // any `+`/`-` mutation flips the observable date fields.
101        let cases: &[(u64, &str)] = &[
102            (0, "1970-01-01T00:00:00.000Z"),
103            (1_709_251_200_000, "2024-03-01T00:00:00.000Z"),
104            (4_102_358_400_000, "2099-12-31T00:00:00.000Z"),
105            (951_782_400_000, "2000-02-29T00:00:00.000Z"),
106        ];
107        for (epoch_ms, expected) in cases {
108            assert_eq!(&iso8601_ms(*epoch_ms), expected, "epoch_ms={epoch_ms}");
109        }
110    }
111
112    // ------------------------------------------------------------------
113    // Stress tests: time-related error conditions across machines/UTC.
114    // ------------------------------------------------------------------
115
116    /// Output must always be UTC (Zulu) — the code has no timezone lookup and
117    /// callers on machines with non-UTC local time still receive UTC.
118    #[test]
119    fn iso8601_ms_always_emits_utc_zulu() {
120        for &epoch_ms in &[0u64, 1_000, 1_786_320_000_000, 4_102_444_800_000] {
121            let iso = iso8601_ms(epoch_ms);
122            assert!(iso.ends_with('Z'), "must end with Z: {iso}");
123            assert!(!iso.contains('+'), "must not carry a `+HH:MM` offset: {iso}");
124            assert!(
125                !iso[1..].contains('-') || iso.matches('-').count() == 2,
126                "only date separators are allowed: {iso}",
127            );
128        }
129    }
130
131    /// Millisecond precision must be honored for every value in `0..1000`.
132    #[test]
133    fn iso8601_ms_millisecond_precision_is_lossless() {
134        let base = 1_786_320_000_000u64; // 2026-08-10T00:00:00.000Z
135        for ms in 0..1000 {
136            let iso = iso8601_ms(base + ms);
137            let expected = format!("2026-08-10T00:00:00.{ms:03}Z");
138            assert_eq!(iso, expected, "ms={ms}");
139        }
140    }
141
142    /// Same epoch value always produces the same string, regardless of the
143    /// machine's timezone, DST state, or how many other threads called it.
144    #[test]
145    fn iso8601_ms_is_deterministic_across_calls() {
146        let cases = [0u64, 1_000, 86_400_000, 1_786_320_000_000, 4_102_444_800_000];
147        for &epoch_ms in &cases {
148            let a = iso8601_ms(epoch_ms);
149            let b = iso8601_ms(epoch_ms);
150            let c = iso8601_ms(epoch_ms);
151            assert_eq!(a, b);
152            assert_eq!(b, c);
153        }
154    }
155
156    /// The four-digit year format holds for every representable second through
157    /// the end of year 9999 (23:59:59.999Z), the last instant a fixed-width
158    /// 24-char timestamp can encode.
159    #[test]
160    fn iso8601_ms_stays_24_chars_through_year_9999() {
161        // 9999-12-31T23:59:59.999Z
162        let last_4digit_ms = 253_402_300_799_999u64;
163        let iso = iso8601_ms(last_4digit_ms);
164        assert_eq!(iso, "9999-12-31T23:59:59.999Z");
165        assert_eq!(iso.len(), 24, "must stay at 24 chars through year 9999");
166    }
167
168    /// u64::MAX epoch_ms must not panic — behavior beyond year 9999 is
169    /// out-of-spec (the format widens past 24 chars) but callers hitting
170    /// pathological values from bug or attack should observe graceful output,
171    /// never a crash or an overflow abort.
172    #[test]
173    fn iso8601_ms_does_not_panic_at_u64_max() {
174        let iso = iso8601_ms(u64::MAX);
175        assert!(iso.ends_with('Z'), "still emits UTC-Z: {iso}");
176        assert!(iso.len() >= 24, "still valid-shape: {iso}");
177    }
178
179    /// Wall-clock time must not run backward as observed by consecutive
180    /// `now_ms()` calls under normal operation. Two rapid samples might tie,
181    /// but the second is never less than the first. (Pre-epoch clocks are a
182    /// separate concern: `now_ms` saturates at 0 rather than panicking, which
183    /// this smoke test cannot induce.)
184    #[test]
185    fn now_ms_never_panics_and_is_monotonic_under_normal_operation() {
186        let mut previous = now_ms();
187        for _ in 0..1_000 {
188            let current = now_ms();
189            assert!(
190                current >= previous,
191                "wall clock ran backward: {previous} -> {current}",
192            );
193            previous = current;
194        }
195    }
196
197    /// `now_iso8601()` must always round-trip its shape invariant (24 chars,
198    /// ends with `Z`) for any real-world clock reading.
199    #[test]
200    fn now_iso8601_shape_holds_for_real_clock() {
201        for _ in 0..100 {
202            let iso = now_iso8601();
203            assert!(iso.ends_with('Z'), "{iso}");
204            assert_eq!(iso.len(), 24, "{iso}");
205            // dashes at positions 4 and 7, T at 10, colons at 13, 16, dot at 19.
206            let bytes = iso.as_bytes();
207            assert_eq!(bytes[4], b'-');
208            assert_eq!(bytes[7], b'-');
209            assert_eq!(bytes[10], b'T');
210            assert_eq!(bytes[13], b':');
211            assert_eq!(bytes[16], b':');
212            assert_eq!(bytes[19], b'.');
213        }
214    }
215
216    /// The Feb-28 -> Mar-1 boundary must respect leap-year rules for every
217    /// 1-, 4-, 100-, and 400-year cycle representable within u64.
218    #[test]
219    fn iso8601_ms_leap_year_boundaries_are_correct() {
220        // (feb_28_ms, expected_next_day_iso).
221        // 2000: 400-year rule -> leap, so Feb 29 exists.
222        // 2100: 100-year but not 400 -> non-leap, Feb 28 -> Mar 1.
223        // 2400: 400-year -> leap, Feb 29 exists.
224        // 2024: simple 4-year -> leap, Feb 29 exists.
225        // 2023: not divisible by 4 -> non-leap, Feb 28 -> Mar 1.
226        let cases: &[(u64, &str)] = &[
227            (951_696_000_000, "2000-02-29T00:00:00.000Z"), // 2000-02-28 + 1 day
228            (4_107_456_000_000, "2100-03-01T00:00:00.000Z"), // 2100-02-28 + 1 day
229            (
230                13_569_465_600_000 + 58 * crate::units::MS_PER_DAY,
231                "2400-02-29T00:00:00.000Z",
232            ), // 2400
233            (1_709_078_400_000, "2024-02-29T00:00:00.000Z"), // 2024-02-28 + 1 day
234            (1_677_542_400_000, "2023-03-01T00:00:00.000Z"), // 2023-02-28 + 1 day
235        ];
236        for &(feb_28_ms, expected_next) in cases {
237            let next_day = iso8601_ms(feb_28_ms + crate::units::MS_PER_DAY);
238            assert_eq!(next_day, expected_next, "feb_28_ms={feb_28_ms}");
239        }
240    }
241
242    /// The elapsed_us helper never panics regardless of how far in the past
243    /// `Instant` was sampled, and its output stays monotone-nondecreasing for
244    /// samples taken in-order from the same instant.
245    #[test]
246    fn elapsed_us_never_panics_and_is_monotone() {
247        let started = std::time::Instant::now();
248        let a = elapsed_us(started);
249        let b = elapsed_us(started);
250        assert!(b >= a, "elapsed_us reversed: {a} -> {b}");
251        assert!(a < u64::MAX);
252    }
253}
254
255#[cfg(test)]
256mod calendar_tests {
257    /// Mutation-run hardening (round 12): pin Hinnant's calendar math
258    /// through the public formatter at epoch, a 400-rule leap day, and
259    /// a century non-leap boundary — kills the `z + 719_468` arithmetic
260    /// mutants that would shift every rendered audit timestamp.
261    #[test]
262    fn iso8601_ms_pins_known_dates() {
263        assert_eq!(super::iso8601_ms(0), "1970-01-01T00:00:00.000Z");
264        assert_eq!(super::iso8601_ms(951_782_400_000), "2000-02-29T00:00:00.000Z");
265        assert_eq!(super::iso8601_ms(4_107_542_399_000), "2100-02-28T23:59:59.000Z");
266    }
267}