Skip to main content

socket_patch_core/vex/
time.rs

1//! Minimal RFC 3339 timestamp formatter from `SystemTime`.
2//!
3//! We only need UTC output with a trailing `Z` (no timezone offsets, no
4//! sub-second precision) — vexctl accepts both forms. Doing this by hand
5//! avoids a chrono/jiff dependency for ~30 lines of arithmetic.
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Format the current time as RFC 3339 in UTC, e.g. `2024-05-24T12:34:56Z`.
10pub fn now_rfc3339() -> String {
11    let secs = SystemTime::now()
12        .duration_since(UNIX_EPOCH)
13        .map(|d| d.as_secs())
14        .unwrap_or(0);
15    format_unix_secs_rfc3339(secs)
16}
17
18/// Format an absolute UNIX-epoch second count as RFC 3339 UTC.
19///
20/// Pulled out as its own function so the formatting can be unit-tested
21/// against fixed timestamps without mocking the system clock.
22pub fn format_unix_secs_rfc3339(secs: u64) -> String {
23    let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs);
24    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
25}
26
27/// Convert a UNIX-epoch second count into a (Y, M, D, h, m, s) tuple in UTC.
28///
29/// Uses the civil_from_days algorithm by Howard Hinnant (public domain):
30/// <http://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
31/// Adapted to operate on a non-negative second count — socket-patch only
32/// ever stamps "now", so pre-1970 inputs are out of scope.
33fn unix_to_ymdhms(secs: u64) -> (i32, u32, u32, u32, u32, u32) {
34    let days = (secs / 86_400) as i64;
35    let secs_of_day = (secs % 86_400) as u32;
36    let hour = secs_of_day / 3600;
37    let minute = (secs_of_day % 3600) / 60;
38    let second = secs_of_day % 60;
39
40    // civil_from_days: days since 1970-01-01 → (Y, M, D).
41    // `z` is `days + 719_468`. Since `days` is derived from a `u64`
42    // input via `secs / 86_400` cast to `i64`, `z` is always
43    // non-negative for any plausible socket-patch input (the cast
44    // would have to wrap around `i64::MAX` to produce a negative,
45    // which requires `secs > i64::MAX * 86_400` — far past the
46    // year 292 billion). The `else { z - 146_096 }` arm is kept
47    // for algorithmic correctness against the Hinnant reference,
48    // but is unreachable in practice and llvm-cov reports it as
49    // such.
50    let z = days + 719_468;
51    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
52    let doe = (z - era * 146_097) as u64;
53    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
54    let y = (yoe as i64) + era * 400;
55    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
56    let mp = (5 * doy + 2) / 153;
57    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
58    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
59    let year = (y + if m <= 2 { 1 } else { 0 }) as i32;
60
61    (year, m, d, hour, minute, second)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn epoch_renders_as_1970_01_01() {
70        assert_eq!(format_unix_secs_rfc3339(0), "1970-01-01T00:00:00Z");
71    }
72
73    #[test]
74    fn known_timestamp_2024_01_01() {
75        // 1704067200 = 2024-01-01T00:00:00Z (verified via `date -u -d ...`).
76        assert_eq!(
77            format_unix_secs_rfc3339(1_704_067_200),
78            "2024-01-01T00:00:00Z"
79        );
80    }
81
82    #[test]
83    fn known_timestamp_with_time_of_day() {
84        // 1716552896 = 2024-05-24T12:14:56Z
85        assert_eq!(
86            format_unix_secs_rfc3339(1_716_552_896),
87            "2024-05-24T12:14:56Z"
88        );
89    }
90
91    #[test]
92    fn leap_year_feb_29() {
93        // 2024-02-29T00:00:00Z = 1709164800
94        assert_eq!(
95            format_unix_secs_rfc3339(1_709_164_800),
96            "2024-02-29T00:00:00Z"
97        );
98    }
99
100    #[test]
101    fn now_has_z_suffix_and_t_separator() {
102        // Sanity check the live function — it must always have the
103        // `YYYY-MM-DDTHH:MM:SSZ` shape regardless of the actual clock.
104        let s = now_rfc3339();
105        assert_eq!(s.len(), 20);
106        assert_eq!(&s[4..5], "-");
107        assert_eq!(&s[7..8], "-");
108        assert_eq!(&s[10..11], "T");
109        assert_eq!(&s[13..14], ":");
110        assert_eq!(&s[16..17], ":");
111        assert!(s.ends_with('Z'));
112    }
113
114    // ── Calendar-algorithm branch coverage ────────────────────────
115
116    /// Non-leap February: 2023-02-28 23:59:59 → 2023-03-01 00:00:00.
117    /// Year 2023 is divisible by neither 4 nor 100/400 → Feb has 28
118    /// days. Pins the `doe / 36524` adjustment in the
119    /// civil_from_days algorithm.
120    #[test]
121    fn non_leap_year_feb_to_march_boundary() {
122        assert_eq!(
123            format_unix_secs_rfc3339(1_677_628_799),
124            "2023-02-28T23:59:59Z"
125        );
126        assert_eq!(
127            format_unix_secs_rfc3339(1_677_628_800),
128            "2023-03-01T00:00:00Z"
129        );
130    }
131
132    /// Year-end roll: 2023-12-31 23:59:59 → 2024-01-01 00:00:00.
133    /// Exercises the month-to-day-of-year inverse mapping at the
134    /// extreme high end.
135    #[test]
136    fn december_to_january_year_boundary() {
137        assert_eq!(
138            format_unix_secs_rfc3339(1_704_067_199),
139            "2023-12-31T23:59:59Z"
140        );
141        assert_eq!(
142            format_unix_secs_rfc3339(1_704_067_200),
143            "2024-01-01T00:00:00Z"
144        );
145    }
146
147    /// 2100 is divisible by 100 but NOT by 400 → it is NOT a leap
148    /// year. Pinning this catches a bug where the algorithm forgets
149    /// the `doe / 146_096` correction in the era arithmetic.
150    /// Picked 2100-03-01 (1 day after the "would be Feb 29 in a
151    /// naive impl" boundary).
152    #[test]
153    fn century_year_2100_is_not_a_leap_year() {
154        assert_eq!(
155            format_unix_secs_rfc3339(4_107_542_400),
156            "2100-03-01T00:00:00Z"
157        );
158    }
159
160    /// 2000 IS a leap year (divisible by 400). Feb 29 2000 should
161    /// render correctly — the four-century cycle reset point.
162    #[test]
163    fn four_century_year_2000_is_a_leap_year() {
164        assert_eq!(
165            format_unix_secs_rfc3339(951_782_400),
166            "2000-02-29T00:00:00Z"
167        );
168    }
169
170    /// 31-day months → 1st of next month. January→February.
171    #[test]
172    fn january_31_to_february_1() {
173        assert_eq!(
174            format_unix_secs_rfc3339(1_675_209_599),
175            "2023-01-31T23:59:59Z"
176        );
177        assert_eq!(
178            format_unix_secs_rfc3339(1_675_209_600),
179            "2023-02-01T00:00:00Z"
180        );
181    }
182
183    /// 31-day month → 30-day month: March 31 → April 1.
184    #[test]
185    fn march_31_to_april_1() {
186        assert_eq!(
187            format_unix_secs_rfc3339(1_680_307_199),
188            "2023-03-31T23:59:59Z"
189        );
190        assert_eq!(
191            format_unix_secs_rfc3339(1_680_307_200),
192            "2023-04-01T00:00:00Z"
193        );
194    }
195
196    /// 30-day month → 31-day month: April 30 → May 1.
197    #[test]
198    fn april_30_to_may_1() {
199        assert_eq!(
200            format_unix_secs_rfc3339(1_682_899_199),
201            "2023-04-30T23:59:59Z"
202        );
203        assert_eq!(
204            format_unix_secs_rfc3339(1_682_899_200),
205            "2023-05-01T00:00:00Z"
206        );
207    }
208
209    /// 30-day month → 31-day month, second half of year:
210    /// September 30 → October 1.
211    #[test]
212    fn september_30_to_october_1() {
213        assert_eq!(
214            format_unix_secs_rfc3339(1_696_118_399),
215            "2023-09-30T23:59:59Z"
216        );
217        assert_eq!(
218            format_unix_secs_rfc3339(1_696_118_400),
219            "2023-10-01T00:00:00Z"
220        );
221    }
222
223    /// Century years 2200 and 2300 are divisible by 100 but NOT by
224    /// 400, so neither has a Feb 29 — Feb 28 must roll straight to
225    /// Mar 1. Complements `century_year_2100_is_not_a_leap_year` and
226    /// guards the `doe / 36_524` / `doe / 146_096` era corrections at
227    /// timestamps where the `era` quotient is ≥ 1 (post-2099).
228    #[test]
229    fn far_future_century_years_are_not_leap() {
230        assert_eq!(
231            format_unix_secs_rfc3339(7_263_215_999),
232            "2200-02-28T23:59:59Z"
233        );
234        assert_eq!(
235            format_unix_secs_rfc3339(7_263_216_000),
236            "2200-03-01T00:00:00Z"
237        );
238        assert_eq!(
239            format_unix_secs_rfc3339(10_418_889_599),
240            "2300-02-28T23:59:59Z"
241        );
242        assert_eq!(
243            format_unix_secs_rfc3339(10_418_889_600),
244            "2300-03-01T00:00:00Z"
245        );
246    }
247
248    /// 2400 is divisible by 400 → leap year, so Feb 29 2400 exists.
249    /// This is the four-century reset point one full era past 2000,
250    /// exercising the `era * 400` year reconstruction with `era` ≥ 1.
251    #[test]
252    fn year_2400_is_a_leap_year() {
253        assert_eq!(
254            format_unix_secs_rfc3339(13_574_606_400),
255            "2400-02-29T12:00:00Z"
256        );
257    }
258
259    /// A far-future leap day (2248-02-29) with a non-trivial time of
260    /// day. Pins the full Y/M/D/h/m/s reconstruction at a timestamp
261    /// well into the `era == 1` range.
262    #[test]
263    fn far_future_leap_day_with_time_of_day() {
264        assert_eq!(
265            format_unix_secs_rfc3339(8_777_917_815),
266            "2248-02-29T06:30:15Z"
267        );
268    }
269
270    /// Time-of-day rollovers: second→minute, minute→hour, and the
271    /// noon midpoint. The date-boundary tests above never cross a
272    /// `:59 → :00` minute/hour carry within a fixed day, so these pin
273    /// the `secs_of_day` div/mod arithmetic directly.
274    #[test]
275    fn time_of_day_rollovers() {
276        let cases: &[(u64, &str)] = &[
277            (1_704_067_259, "2024-01-01T00:00:59Z"), // last second of minute 0
278            (1_704_067_260, "2024-01-01T00:01:00Z"), // minute carry
279            (1_704_070_799, "2024-01-01T00:59:59Z"), // last second of hour 0
280            (1_704_070_800, "2024-01-01T01:00:00Z"), // hour carry
281            (1_704_110_400, "2024-01-01T12:00:00Z"), // noon
282        ];
283        for &(secs, expected) in cases {
284            assert_eq!(format_unix_secs_rfc3339(secs), expected, "secs={secs}");
285        }
286    }
287
288    /// `u64::MAX` does not panic. Output isn't asserted byte-for-byte
289    /// because the algorithm uses an `i64` cast that overflows in
290    /// well-defined wrapping in debug-release but the function MUST
291    /// not crash. Exercise the path and confirm the format shape
292    /// (digits-dash-digits-T-digits...) is preserved.
293    #[test]
294    fn max_u64_input_does_not_panic() {
295        // Wrap in `std::panic::catch_unwind` for safety even though
296        // the function uses pure arithmetic — a regression that
297        // introduced an unsafe cast would still be caught.
298        let result = std::panic::catch_unwind(|| format_unix_secs_rfc3339(u64::MAX));
299        assert!(result.is_ok(), "u64::MAX must not panic");
300        // The output shape should still end in `Z`.
301        let s = result.unwrap();
302        assert!(s.ends_with('Z'), "output must still end with Z");
303    }
304
305    /// `now_rfc3339` must produce a string that round-trips through
306    /// our own `format_unix_secs_rfc3339` — i.e. the year/month/day
307    /// fields are within plausible ranges (years 1970..3000, months
308    /// 01-12, days 01-31). Smoke gate against a future regression
309    /// where the system clock format diverges from our manual one.
310    #[test]
311    fn now_output_parses_into_plausible_fields() {
312        let s = now_rfc3339();
313        let year: u32 = s[0..4].parse().unwrap();
314        let month: u32 = s[5..7].parse().unwrap();
315        let day: u32 = s[8..10].parse().unwrap();
316        let hour: u32 = s[11..13].parse().unwrap();
317        let minute: u32 = s[14..16].parse().unwrap();
318        let second: u32 = s[17..19].parse().unwrap();
319        assert!((1970..3000).contains(&year), "year out of range: {year}");
320        assert!((1..=12).contains(&month), "month out of range: {month}");
321        assert!((1..=31).contains(&day), "day out of range: {day}");
322        assert!(hour < 24);
323        assert!(minute < 60);
324        assert!(second < 60);
325    }
326}