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 /// `u64::MAX` does not panic. Output isn't asserted byte-for-byte
224 /// because the algorithm uses an `i64` cast that overflows in
225 /// well-defined wrapping in debug-release but the function MUST
226 /// not crash. Exercise the path and confirm the format shape
227 /// (digits-dash-digits-T-digits...) is preserved.
228 #[test]
229 fn max_u64_input_does_not_panic() {
230 // Wrap in `std::panic::catch_unwind` for safety even though
231 // the function uses pure arithmetic — a regression that
232 // introduced an unsafe cast would still be caught.
233 let result = std::panic::catch_unwind(|| {
234 format_unix_secs_rfc3339(u64::MAX)
235 });
236 assert!(result.is_ok(), "u64::MAX must not panic");
237 // The output shape should still end in `Z`.
238 let s = result.unwrap();
239 assert!(s.ends_with('Z'), "output must still end with Z");
240 }
241
242 /// `now_rfc3339` must produce a string that round-trips through
243 /// our own `format_unix_secs_rfc3339` — i.e. the year/month/day
244 /// fields are within plausible ranges (years 1970..3000, months
245 /// 01-12, days 01-31). Smoke gate against a future regression
246 /// where the system clock format diverges from our manual one.
247 #[test]
248 fn now_output_parses_into_plausible_fields() {
249 let s = now_rfc3339();
250 let year: u32 = s[0..4].parse().unwrap();
251 let month: u32 = s[5..7].parse().unwrap();
252 let day: u32 = s[8..10].parse().unwrap();
253 let hour: u32 = s[11..13].parse().unwrap();
254 let minute: u32 = s[14..16].parse().unwrap();
255 let second: u32 = s[17..19].parse().unwrap();
256 assert!((1970..3000).contains(&year), "year out of range: {year}");
257 assert!((1..=12).contains(&month), "month out of range: {month}");
258 assert!((1..=31).contains(&day), "day out of range: {day}");
259 assert!(hour < 24);
260 assert!(minute < 60);
261 assert!(second < 60);
262 }
263}