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    /// Mutation-run hardening: `elapsed_us` feeds SLA metrics and verdict
65    /// latency fields; mutants returning 0/1 survived. 10 ms of real sleep
66    /// must measure as at least 2 000 µs on any scheduler.
67    #[test]
68    fn elapsed_us_measures_real_time() {
69        let started = std::time::Instant::now();
70        std::thread::sleep(std::time::Duration::from_millis(10));
71        assert!(elapsed_us(started) >= 2_000);
72    }
73
74    #[test]
75    fn epoch_zero() {
76        assert_eq!(iso8601_ms(0), "1970-01-01T00:00:00.000Z");
77    }
78
79    #[test]
80    fn known_timestamp() {
81        // 2026-08-10T00:00:00Z == 1786320000000 ms (20675 days × 86400 s)
82        assert_eq!(iso8601_ms(1_786_320_000_000), "2026-08-10T00:00:00.000Z");
83    }
84
85    /// Round-23 mutation finding: `iso8601_ms` takes u64, so the
86    /// negative-era branch of Hinnant's civil_from_days (`z - 146_096`)
87    /// is unreachable through the public API and its mutants survived.
88    /// The algorithm is deliberately kept general (i64) — pin the
89    /// pre-epoch branch directly so a future signed-epoch caller
90    /// inherits verified math, and pin the era boundaries the public
91    /// path CAN reach.
92    #[test]
93    fn civil_from_days_handles_negative_days_and_era_boundaries() {
94        // Day -1 = the day before the epoch.
95        assert_eq!(civil_from_days(-1), (1969, 12, 31));
96        // z == 0 boundary (day -719,468) = 0000-03-01, the era origin.
97        assert_eq!(civil_from_days(-719_468), (0, 3, 1));
98        // One day earlier crosses into the NEGATIVE-era branch: year 0
99        // is a proleptic leap year, so it's 0000-02-29.
100        assert_eq!(civil_from_days(-719_469), (0, 2, 29));
101        // Reachable era boundaries: century leap (2000-02-29) and the
102        // 2100 non-leap century (2100-02-28 is the last Feb day).
103        assert_eq!(civil_from_days(11_016), (2000, 2, 29));
104        assert_eq!(civil_from_days(47_540), (2100, 2, 28));
105        assert_eq!(civil_from_days(47_541), (2100, 3, 1));
106    }
107
108    #[test]
109    fn leap_year_feb_29() {
110        // 2024-02-29T12:34:56.789Z == 1709210096789
111        assert_eq!(iso8601_ms(1_709_210_096_789), "2024-02-29T12:34:56.789Z");
112    }
113
114    #[test]
115    fn year_2038_safe() {
116        // 2100-01-01T00:00:00Z = 4102444800000 ms — u64 ms has centuries of headroom.
117        assert_eq!(iso8601_ms(4_102_444_800_000), "2100-01-01T00:00:00.000Z");
118    }
119
120    #[test]
121    fn now_is_sane() {
122        let now = now_ms();
123        assert!(now > 1_700_000_000_000, "clock reads before Nov 2023: {now}");
124        let iso = now_iso8601();
125        assert!(iso.ends_with('Z') && iso.len() == 24, "bad iso format: {iso}");
126    }
127
128    #[test]
129    fn civil_from_days_exercises_month_and_day_arithmetic() {
130        // Epoch day zero, Mar 1 2024 (day after a leap Feb 29), Dec 31 2099
131        // (last day before the 100-year non-leap boundary), and Feb 29 2000
132        // (400-year leap). Each hits a different branch of civil_from_days;
133        // any `+`/`-` mutation flips the observable date fields.
134        let cases: &[(u64, &str)] = &[
135            (0, "1970-01-01T00:00:00.000Z"),
136            (1_709_251_200_000, "2024-03-01T00:00:00.000Z"),
137            (4_102_358_400_000, "2099-12-31T00:00:00.000Z"),
138            (951_782_400_000, "2000-02-29T00:00:00.000Z"),
139        ];
140        for (epoch_ms, expected) in cases {
141            assert_eq!(&iso8601_ms(*epoch_ms), expected, "epoch_ms={epoch_ms}");
142        }
143    }
144
145    // ------------------------------------------------------------------
146    // Stress tests: time-related error conditions across machines/UTC.
147    // ------------------------------------------------------------------
148
149    /// Output must always be UTC (Zulu) — the code has no timezone lookup and
150    /// callers on machines with non-UTC local time still receive UTC.
151    #[test]
152    fn iso8601_ms_always_emits_utc_zulu() {
153        for &epoch_ms in &[0u64, 1_000, 1_786_320_000_000, 4_102_444_800_000] {
154            let iso = iso8601_ms(epoch_ms);
155            assert!(iso.ends_with('Z'), "must end with Z: {iso}");
156            assert!(!iso.contains('+'), "must not carry a `+HH:MM` offset: {iso}");
157            assert!(
158                !iso[1..].contains('-') || iso.matches('-').count() == 2,
159                "only date separators are allowed: {iso}",
160            );
161        }
162    }
163
164    /// Millisecond precision must be honored for every value in `0..1000`.
165    #[test]
166    fn iso8601_ms_millisecond_precision_is_lossless() {
167        let base = 1_786_320_000_000u64; // 2026-08-10T00:00:00.000Z
168        for ms in 0..1000 {
169            let iso = iso8601_ms(base + ms);
170            let expected = format!("2026-08-10T00:00:00.{ms:03}Z");
171            assert_eq!(iso, expected, "ms={ms}");
172        }
173    }
174
175    /// Same epoch value always produces the same string, regardless of the
176    /// machine's timezone, DST state, or how many other threads called it.
177    #[test]
178    fn iso8601_ms_is_deterministic_across_calls() {
179        let cases = [0u64, 1_000, 86_400_000, 1_786_320_000_000, 4_102_444_800_000];
180        for &epoch_ms in &cases {
181            let a = iso8601_ms(epoch_ms);
182            let b = iso8601_ms(epoch_ms);
183            let c = iso8601_ms(epoch_ms);
184            assert_eq!(a, b);
185            assert_eq!(b, c);
186        }
187    }
188
189    /// The four-digit year format holds for every representable second through
190    /// the end of year 9999 (23:59:59.999Z), the last instant a fixed-width
191    /// 24-char timestamp can encode.
192    #[test]
193    fn iso8601_ms_stays_24_chars_through_year_9999() {
194        // 9999-12-31T23:59:59.999Z
195        let last_4digit_ms = 253_402_300_799_999u64;
196        let iso = iso8601_ms(last_4digit_ms);
197        assert_eq!(iso, "9999-12-31T23:59:59.999Z");
198        assert_eq!(iso.len(), 24, "must stay at 24 chars through year 9999");
199    }
200
201    /// u64::MAX epoch_ms must not panic — behavior beyond year 9999 is
202    /// out-of-spec (the format widens past 24 chars) but callers hitting
203    /// pathological values from bug or attack should observe graceful output,
204    /// never a crash or an overflow abort.
205    #[test]
206    fn iso8601_ms_does_not_panic_at_u64_max() {
207        let iso = iso8601_ms(u64::MAX);
208        assert!(iso.ends_with('Z'), "still emits UTC-Z: {iso}");
209        assert!(iso.len() >= 24, "still valid-shape: {iso}");
210    }
211
212    /// Wall-clock time must not run backward as observed by consecutive
213    /// `now_ms()` calls under normal operation. Two rapid samples might tie,
214    /// but the second is never less than the first. (Pre-epoch clocks are a
215    /// separate concern: `now_ms` saturates at 0 rather than panicking, which
216    /// this smoke test cannot induce.)
217    #[test]
218    fn now_ms_never_panics_and_is_monotonic_under_normal_operation() {
219        let mut previous = now_ms();
220        for _ in 0..1_000 {
221            let current = now_ms();
222            assert!(
223                current >= previous,
224                "wall clock ran backward: {previous} -> {current}",
225            );
226            previous = current;
227        }
228    }
229
230    /// `now_iso8601()` must always round-trip its shape invariant (24 chars,
231    /// ends with `Z`) for any real-world clock reading.
232    #[test]
233    fn now_iso8601_shape_holds_for_real_clock() {
234        for _ in 0..100 {
235            let iso = now_iso8601();
236            assert!(iso.ends_with('Z'), "{iso}");
237            assert_eq!(iso.len(), 24, "{iso}");
238            // dashes at positions 4 and 7, T at 10, colons at 13, 16, dot at 19.
239            let bytes = iso.as_bytes();
240            assert_eq!(bytes[4], b'-');
241            assert_eq!(bytes[7], b'-');
242            assert_eq!(bytes[10], b'T');
243            assert_eq!(bytes[13], b':');
244            assert_eq!(bytes[16], b':');
245            assert_eq!(bytes[19], b'.');
246        }
247    }
248
249    /// The Feb-28 -> Mar-1 boundary must respect leap-year rules for every
250    /// 1-, 4-, 100-, and 400-year cycle representable within u64.
251    #[test]
252    fn iso8601_ms_leap_year_boundaries_are_correct() {
253        // (feb_28_ms, expected_next_day_iso).
254        // 2000: 400-year rule -> leap, so Feb 29 exists.
255        // 2100: 100-year but not 400 -> non-leap, Feb 28 -> Mar 1.
256        // 2400: 400-year -> leap, Feb 29 exists.
257        // 2024: simple 4-year -> leap, Feb 29 exists.
258        // 2023: not divisible by 4 -> non-leap, Feb 28 -> Mar 1.
259        let cases: &[(u64, &str)] = &[
260            (951_696_000_000, "2000-02-29T00:00:00.000Z"), // 2000-02-28 + 1 day
261            (4_107_456_000_000, "2100-03-01T00:00:00.000Z"), // 2100-02-28 + 1 day
262            (
263                13_569_465_600_000 + 58 * crate::units::MS_PER_DAY,
264                "2400-02-29T00:00:00.000Z",
265            ), // 2400
266            (1_709_078_400_000, "2024-02-29T00:00:00.000Z"), // 2024-02-28 + 1 day
267            (1_677_542_400_000, "2023-03-01T00:00:00.000Z"), // 2023-02-28 + 1 day
268        ];
269        for &(feb_28_ms, expected_next) in cases {
270            let next_day = iso8601_ms(feb_28_ms + crate::units::MS_PER_DAY);
271            assert_eq!(next_day, expected_next, "feb_28_ms={feb_28_ms}");
272        }
273    }
274
275    /// The elapsed_us helper never panics regardless of how far in the past
276    /// `Instant` was sampled, and its output stays monotone-nondecreasing for
277    /// samples taken in-order from the same instant.
278    #[test]
279    fn elapsed_us_never_panics_and_is_monotone() {
280        let started = std::time::Instant::now();
281        let a = elapsed_us(started);
282        let b = elapsed_us(started);
283        assert!(b >= a, "elapsed_us reversed: {a} -> {b}");
284        assert!(a < u64::MAX);
285    }
286}
287
288#[cfg(test)]
289mod calendar_tests {
290    /// Mutation-run hardening: pin Hinnant's calendar math
291    /// through the public formatter at epoch, a 400-rule leap day, and
292    /// a century non-leap boundary — kills the `z + 719_468` arithmetic
293    /// mutants that would shift every rendered audit timestamp.
294    #[test]
295    fn iso8601_ms_pins_known_dates() {
296        assert_eq!(super::iso8601_ms(0), "1970-01-01T00:00:00.000Z");
297        assert_eq!(super::iso8601_ms(951_782_400_000), "2000-02-29T00:00:00.000Z");
298        assert_eq!(super::iso8601_ms(4_107_542_399_000), "2100-02-28T23:59:59.000Z");
299    }
300}