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}