Skip to main content

vct_core/utils/
time.rs

1//! Timestamp parsing helpers for the ISO-8601 / RFC 3339 strings that the
2//! provider session logs embed in each record.
3
4use chrono::{DateTime, SecondsFormat, Utc};
5
6/// Current UTC time as RFC3339 with nanoseconds and a `Z` suffix
7/// (e.g. `2026-07-07T05:34:50.563606999Z`).
8///
9/// Matches the format Codex writes for `auth.json`'s `last_refresh` and the
10/// stamp the quota version caches / self `version.json` record use.
11pub fn now_rfc3339_utc_nanos() -> String {
12    Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true)
13}
14
15/// Formats `unix_secs` in the same shape [`now_rfc3339_utc_nanos`] produces.
16///
17/// Used to stamp a refreshed token's expiry in the exact format the Grok CLI
18/// writes into `auth.json`. Returns `None` for a timestamp outside the
19/// representable range rather than silently substituting another instant.
20pub fn rfc3339_utc_nanos(unix_secs: i64) -> Option<String> {
21    DateTime::from_timestamp(unix_secs, 0).map(|dt| dt.to_rfc3339_opts(SecondsFormat::Nanos, true))
22}
23
24/// Parses an ISO-8601 / RFC 3339 timestamp into Unix milliseconds.
25///
26/// RFC 3339 is tried first (the common case, with timezone offset or `Z`),
27/// then a small set of explicit UTC fallback patterns covering 3-digit,
28/// arbitrary-precision, and zero-fraction sub-seconds. Surrounding
29/// whitespace is *not* tolerated.
30///
31/// Returns `0` for an empty string or any input that matches none of the
32/// accepted formats; callers treat `0` as "unknown time" rather than the
33/// Unix epoch.
34///
35/// # Examples
36///
37/// ```
38/// use vct_core::utils::parse_iso_timestamp;
39///
40/// assert_eq!(parse_iso_timestamp("1970-01-01T00:00:01Z"), 1_000);
41/// assert_eq!(parse_iso_timestamp(""), 0);
42/// assert_eq!(parse_iso_timestamp("not a timestamp"), 0);
43/// ```
44pub fn parse_iso_timestamp(ts: &str) -> i64 {
45    if ts.is_empty() {
46        return 0;
47    }
48
49    // Try RFC3339 first (most common format)
50    if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
51        return dt.timestamp_millis();
52    }
53
54    // Try other formats
55    let formats = [
56        "%Y-%m-%dT%H:%M:%S%.3fZ",
57        "%Y-%m-%dT%H:%M:%S%.fZ",
58        "%Y-%m-%dT%H:%M:%SZ",
59    ];
60
61    for format in &formats {
62        if let Ok(dt) = DateTime::parse_from_str(ts, format) {
63            return dt.timestamp_millis();
64        }
65    }
66
67    0
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn rfc3339_utc_nanos_round_trips_and_matches_the_now_shape() {
76        let stamp = rfc3339_utc_nanos(1_785_058_368).expect("in range");
77        assert_eq!(stamp, "2026-07-26T09:32:48.000000000Z");
78        // The same shape `now_rfc3339_utc_nanos` writes, and re-readable.
79        assert_eq!(parse_iso_timestamp(&stamp), 1_785_058_368_000);
80        assert_eq!(
81            stamp.len(),
82            now_rfc3339_utc_nanos().len(),
83            "both stamps must be the fixed-width nanosecond form"
84        );
85        // Out of range yields None rather than a substituted instant.
86        assert!(rfc3339_utc_nanos(i64::MAX).is_none());
87    }
88
89    #[test]
90    fn test_parse_iso_timestamp_rfc3339() {
91        // Test parsing RFC3339 format (most common)
92        let ts = "2024-01-15T10:30:45.123Z";
93        let result = parse_iso_timestamp(ts);
94        assert!(result > 0);
95
96        // Should parse to a valid timestamp (2024)
97        assert!(result > 1_700_000_000_000); // After 2023
98        assert!(result < 1_800_000_000_000); // Before 2027
99    }
100
101    #[test]
102    fn test_parse_iso_timestamp_with_timezone() {
103        // Test parsing with timezone offset
104        let ts = "2024-01-15T10:30:45.123+08:00";
105        let result = parse_iso_timestamp(ts);
106        assert!(result > 0);
107    }
108
109    #[test]
110    fn test_parse_iso_timestamp_no_millis() {
111        // Test parsing without milliseconds
112        let ts = "2024-01-15T10:30:45Z";
113        let result = parse_iso_timestamp(ts);
114        assert!(result > 0);
115    }
116
117    #[test]
118    fn test_parse_iso_timestamp_fallback_formats() {
119        // Test fallback format with milliseconds
120        let ts1 = "2024-01-15T10:30:45.123Z";
121        let result1 = parse_iso_timestamp(ts1);
122        assert!(result1 > 0);
123
124        // Test fallback format with fractional seconds
125        let ts2 = "2024-01-15T10:30:45.123456Z";
126        let result2 = parse_iso_timestamp(ts2);
127        assert!(result2 > 0);
128
129        // Test fallback format without fractional seconds
130        let ts3 = "2024-01-15T10:30:45Z";
131        let result3 = parse_iso_timestamp(ts3);
132        assert!(result3 > 0);
133    }
134
135    #[test]
136    fn test_parse_iso_timestamp_empty() {
137        // Test parsing empty string
138        let result = parse_iso_timestamp("");
139        assert_eq!(result, 0);
140    }
141
142    #[test]
143    fn test_parse_iso_timestamp_invalid() {
144        // Test parsing invalid format
145        let result = parse_iso_timestamp("not a timestamp");
146        assert_eq!(result, 0);
147
148        let result = parse_iso_timestamp("2024-13-45");
149        assert_eq!(result, 0);
150
151        let result = parse_iso_timestamp("invalid-date-time");
152        assert_eq!(result, 0);
153    }
154
155    #[test]
156    fn test_parse_iso_timestamp_different_years() {
157        // Test different years to ensure parsing is consistent
158        let ts_2020 = "2020-06-15T12:00:00Z";
159        let ts_2024 = "2024-06-15T12:00:00Z";
160
161        let result_2020 = parse_iso_timestamp(ts_2020);
162        let result_2024 = parse_iso_timestamp(ts_2024);
163
164        assert!(result_2020 > 0);
165        assert!(result_2024 > 0);
166        assert!(result_2024 > result_2020);
167    }
168
169    #[test]
170    fn test_parse_iso_timestamp_milliseconds_precision() {
171        // Test that milliseconds are preserved
172        let ts1 = "2024-01-15T10:30:45.000Z";
173        let ts2 = "2024-01-15T10:30:45.999Z";
174
175        let result1 = parse_iso_timestamp(ts1);
176        let result2 = parse_iso_timestamp(ts2);
177
178        assert!(result1 > 0);
179        assert!(result2 > 0);
180        // Should be ~999ms apart
181        assert!(result2 > result1);
182        assert!(result2 - result1 < 1000);
183    }
184
185    #[test]
186    fn test_parse_iso_timestamp_same_time() {
187        // Test parsing the same timestamp twice
188        let ts = "2024-01-15T10:30:45.123Z";
189        let result1 = parse_iso_timestamp(ts);
190        let result2 = parse_iso_timestamp(ts);
191
192        assert_eq!(result1, result2);
193    }
194
195    #[test]
196    fn test_parse_iso_timestamp_edge_cases() {
197        // Test edge cases
198
199        // Beginning of year
200        let ts1 = "2024-01-01T00:00:00Z";
201        let result1 = parse_iso_timestamp(ts1);
202        assert!(result1 > 0);
203
204        // End of year
205        let ts2 = "2024-12-31T23:59:59Z";
206        let result2 = parse_iso_timestamp(ts2);
207        assert!(result2 > 0);
208        assert!(result2 > result1);
209
210        // Leap year day
211        let ts3 = "2024-02-29T12:00:00Z";
212        let result3 = parse_iso_timestamp(ts3);
213        assert!(result3 > 0);
214    }
215
216    #[test]
217    fn test_parse_iso_timestamp_negative_timezone() {
218        // Test parsing with negative timezone offset
219        let ts = "2024-01-15T10:30:45.123-05:00";
220        let result = parse_iso_timestamp(ts);
221        assert!(result > 0);
222    }
223
224    #[test]
225    fn test_parse_iso_timestamp_midnight() {
226        // Test midnight timestamps
227        let ts = "2024-01-15T00:00:00.000Z";
228        let result = parse_iso_timestamp(ts);
229        assert!(result > 0);
230    }
231
232    #[test]
233    fn test_parse_iso_timestamp_noon() {
234        // Test noon timestamps
235        let ts = "2024-01-15T12:00:00.000Z";
236        let result = parse_iso_timestamp(ts);
237        assert!(result > 0);
238    }
239
240    #[test]
241    fn test_parse_iso_timestamp_whitespace() {
242        // Test that whitespace is not tolerated
243        let result = parse_iso_timestamp(" 2024-01-15T10:30:45Z ");
244        assert_eq!(result, 0);
245    }
246
247    #[test]
248    fn test_parse_iso_timestamp_partial() {
249        // Test partial timestamps (invalid)
250        let result = parse_iso_timestamp("2024-01-15");
251        assert_eq!(result, 0);
252
253        let result = parse_iso_timestamp("2024-01-15T10:30");
254        assert_eq!(result, 0);
255    }
256
257    #[test]
258    fn test_parse_iso_timestamp_ordering() {
259        // Test that timestamps maintain proper ordering
260        let timestamps = [
261            "2024-01-15T10:00:00Z",
262            "2024-01-15T11:00:00Z",
263            "2024-01-15T12:00:00Z",
264            "2024-01-15T13:00:00Z",
265        ];
266
267        let results: Vec<i64> = timestamps
268            .iter()
269            .map(|ts| parse_iso_timestamp(ts))
270            .collect();
271
272        // All should be non-zero
273        assert!(results.iter().all(|&r| r > 0));
274
275        // Should be in ascending order
276        for i in 1..results.len() {
277            assert!(results[i] > results[i - 1]);
278        }
279    }
280}