Skip to main content

cjc_runtime/
datetime.rs

1//! DateTime support for CJC.
2//!
3//! Design decisions:
4//! - Epoch millis (i64), UTC only — deterministic, no timezone ambiguity
5//! - `datetime_now()` is NONDET (uses system clock)
6//! - All other operations are pure arithmetic on epoch millis
7//! - Leap year handling for year/month/day extraction
8
9// ---------------------------------------------------------------------------
10// Constants
11// ---------------------------------------------------------------------------
12
13const MILLIS_PER_SECOND: i64 = 1_000;
14const MILLIS_PER_MINUTE: i64 = 60 * MILLIS_PER_SECOND;
15const MILLIS_PER_HOUR: i64 = 60 * MILLIS_PER_MINUTE;
16const MILLIS_PER_DAY: i64 = 24 * MILLIS_PER_HOUR;
17
18// Days in each month (non-leap year)
19const DAYS_IN_MONTH: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
20
21// ---------------------------------------------------------------------------
22// Core functions
23// ---------------------------------------------------------------------------
24
25/// Returns current UTC time as epoch milliseconds.
26/// This is NONDET — the only nondeterministic datetime operation.
27pub fn datetime_now() -> i64 {
28    std::time::SystemTime::now()
29        .duration_since(std::time::UNIX_EPOCH)
30        .unwrap_or_default()
31        .as_millis() as i64
32}
33
34/// Create a datetime from epoch milliseconds (identity, but validates type).
35pub fn datetime_from_epoch(millis: i64) -> i64 {
36    millis
37}
38
39/// Create a datetime from year, month, day, hour, minute, second components.
40/// All components are 1-based for month/day.
41pub fn datetime_from_parts(year: i64, month: i64, day: i64, hour: i64, min: i64, sec: i64) -> i64 {
42    let days = days_from_civil(year, month, day);
43    days * MILLIS_PER_DAY + hour * MILLIS_PER_HOUR + min * MILLIS_PER_MINUTE + sec * MILLIS_PER_SECOND
44}
45
46// ---------------------------------------------------------------------------
47// Extraction (pure arithmetic)
48// ---------------------------------------------------------------------------
49
50/// Extract the year from epoch millis.
51pub fn datetime_year(millis: i64) -> i64 {
52    let (y, _, _) = civil_from_days(millis.div_euclid(MILLIS_PER_DAY));
53    y
54}
55
56/// Extract the month (1-12) from epoch millis.
57pub fn datetime_month(millis: i64) -> i64 {
58    let (_, m, _) = civil_from_days(millis.div_euclid(MILLIS_PER_DAY));
59    m
60}
61
62/// Extract the day of month (1-31) from epoch millis.
63pub fn datetime_day(millis: i64) -> i64 {
64    let (_, _, d) = civil_from_days(millis.div_euclid(MILLIS_PER_DAY));
65    d
66}
67
68/// Extract the hour (0-23) from epoch millis.
69pub fn datetime_hour(millis: i64) -> i64 {
70    let day_millis = millis.rem_euclid(MILLIS_PER_DAY);
71    day_millis / MILLIS_PER_HOUR
72}
73
74/// Extract the minute (0-59) from epoch millis.
75pub fn datetime_minute(millis: i64) -> i64 {
76    let day_millis = millis.rem_euclid(MILLIS_PER_DAY);
77    (day_millis % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE
78}
79
80/// Extract the second (0-59) from epoch millis.
81pub fn datetime_second(millis: i64) -> i64 {
82    let day_millis = millis.rem_euclid(MILLIS_PER_DAY);
83    (day_millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND
84}
85
86// ---------------------------------------------------------------------------
87// Arithmetic (pure)
88// ---------------------------------------------------------------------------
89
90/// Difference between two datetimes in milliseconds.
91pub fn datetime_diff(a: i64, b: i64) -> i64 {
92    a - b
93}
94
95/// Add milliseconds to a datetime.
96pub fn datetime_add_millis(dt: i64, millis: i64) -> i64 {
97    dt + millis
98}
99
100// ---------------------------------------------------------------------------
101// Formatting (pure)
102// ---------------------------------------------------------------------------
103
104/// Format a datetime as ISO 8601 UTC string: `YYYY-MM-DDTHH:MM:SSZ`
105pub fn datetime_format(millis: i64) -> String {
106    let days = millis.div_euclid(MILLIS_PER_DAY);
107    let (year, month, day) = civil_from_days(days);
108    let day_millis = millis.rem_euclid(MILLIS_PER_DAY);
109    let hour = day_millis / MILLIS_PER_HOUR;
110    let minute = (day_millis % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE;
111    let second = (day_millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND;
112    format!(
113        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
114        year, month, day, hour, minute, second
115    )
116}
117
118// ---------------------------------------------------------------------------
119// Civil date algorithms (adapted from Howard Hinnant's algorithms)
120// ---------------------------------------------------------------------------
121
122/// Return `true` if `year` is a leap year under the Gregorian calendar.
123fn is_leap_year(year: i64) -> bool {
124    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
125}
126
127/// Convert a Gregorian date to days since the Unix epoch (1970-01-01).
128///
129/// Uses Howard Hinnant's civil date algorithm with a March-based year
130/// shift for simplified leap-year handling. Month is 1-based (1 = January),
131/// day is 1-based.
132fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
133    // Shift March to month 1 for easier calculation
134    let (y, m) = if month <= 2 {
135        (year - 1, month + 9)
136    } else {
137        (year, month - 3)
138    };
139    let era = y.div_euclid(400);
140    let yoe = y.rem_euclid(400); // year of era [0, 399]
141    let doy = (153 * m + 2) / 5 + day - 1; // day of year [0, 365]
142    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096]
143    era * 146097 + doe - 719468 // shift to Unix epoch
144}
145
146/// Convert days since the Unix epoch to a `(year, month, day)` triple.
147///
148/// Inverse of [`days_from_civil`]. Month is 1-based, day is 1-based.
149fn civil_from_days(days: i64) -> (i64, i64, i64) {
150    let z = days + 719468;
151    let era = z.div_euclid(146097);
152    let doe = z.rem_euclid(146097); // day of era [0, 146096]
153    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399]
154    let y = yoe + era * 400;
155    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
156    let mp = (5 * doy + 2) / 153; // month index [0, 11]
157    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
158    let m = if mp < 10 { mp + 3 } else { mp - 9 };
159    let y = if m <= 2 { y + 1 } else { y };
160    (y, m, d)
161}
162
163/// Return the number of days in the given month (1-12) of the given year.
164///
165/// Accounts for leap years in February. Returns `0` for out-of-range months.
166pub fn days_in_month(year: i64, month: i64) -> i64 {
167    if month == 2 && is_leap_year(year) {
168        29
169    } else if month >= 1 && month <= 12 {
170        DAYS_IN_MONTH[(month - 1) as usize]
171    } else {
172        0
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Extended date API: parse_date, date_format, date_diff with units, date_add
178// ---------------------------------------------------------------------------
179
180/// Parse a date string according to a format specification.
181///
182/// Supported format tokens: `%Y` (4-digit year), `%m` (month), `%d` (day),
183/// `%H` (hour), `%M` (minute), `%S` (second).
184///
185/// Returns epoch milliseconds on success, 0 on parse failure.
186pub fn parse_date(s: &str, fmt: &str) -> i64 {
187    let mut year: i64 = 1970;
188    let mut month: i64 = 1;
189    let mut day: i64 = 1;
190    let mut hour: i64 = 0;
191    let mut minute: i64 = 0;
192    let mut second: i64 = 0;
193
194    let sbytes = s.as_bytes();
195    let fbytes = fmt.as_bytes();
196    let mut si = 0usize;
197    let mut fi = 0usize;
198
199    while fi < fbytes.len() && si < sbytes.len() {
200        if fbytes[fi] == b'%' && fi + 1 < fbytes.len() {
201            let spec = fbytes[fi + 1];
202            fi += 2;
203            match spec {
204                b'Y' => {
205                    if let Some((val, consumed)) = parse_int_n(sbytes, si, 4) {
206                        year = val;
207                        si += consumed;
208                    } else {
209                        return 0;
210                    }
211                }
212                b'm' => {
213                    if let Some((val, consumed)) = parse_int_max(sbytes, si, 2) {
214                        month = val;
215                        si += consumed;
216                    } else {
217                        return 0;
218                    }
219                }
220                b'd' => {
221                    if let Some((val, consumed)) = parse_int_max(sbytes, si, 2) {
222                        day = val;
223                        si += consumed;
224                    } else {
225                        return 0;
226                    }
227                }
228                b'H' => {
229                    if let Some((val, consumed)) = parse_int_max(sbytes, si, 2) {
230                        hour = val;
231                        si += consumed;
232                    } else {
233                        return 0;
234                    }
235                }
236                b'M' => {
237                    if let Some((val, consumed)) = parse_int_max(sbytes, si, 2) {
238                        minute = val;
239                        si += consumed;
240                    } else {
241                        return 0;
242                    }
243                }
244                b'S' => {
245                    if let Some((val, consumed)) = parse_int_max(sbytes, si, 2) {
246                        second = val;
247                        si += consumed;
248                    } else {
249                        return 0;
250                    }
251                }
252                _ => {
253                    // Unknown specifier — treat as literal %X
254                    if si < sbytes.len() && sbytes[si] == spec {
255                        si += 1;
256                    } else {
257                        return 0;
258                    }
259                }
260            }
261        } else {
262            // Literal character — must match
263            if sbytes[si] == fbytes[fi] {
264                si += 1;
265                fi += 1;
266            } else {
267                return 0;
268            }
269        }
270    }
271
272    datetime_from_parts(year, month, day, hour, minute, second)
273}
274
275/// Format an epoch-millisecond timestamp according to a format specification.
276///
277/// Supported format tokens: `%Y` (4-digit year), `%m` (2-digit month),
278/// `%d` (2-digit day), `%H` (2-digit hour), `%M` (2-digit minute),
279/// `%S` (2-digit second).
280pub fn date_format_custom(ts: i64, fmt: &str) -> String {
281    let days = ts.div_euclid(MILLIS_PER_DAY);
282    let (year, month, day) = civil_from_days(days);
283    let day_millis = ts.rem_euclid(MILLIS_PER_DAY);
284    let hour = day_millis / MILLIS_PER_HOUR;
285    let minute = (day_millis % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE;
286    let second = (day_millis % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND;
287
288    let fbytes = fmt.as_bytes();
289    let mut result = String::new();
290    let mut i = 0;
291    while i < fbytes.len() {
292        if fbytes[i] == b'%' && i + 1 < fbytes.len() {
293            match fbytes[i + 1] {
294                b'Y' => { result.push_str(&format!("{:04}", year)); i += 2; }
295                b'm' => { result.push_str(&format!("{:02}", month)); i += 2; }
296                b'd' => { result.push_str(&format!("{:02}", day)); i += 2; }
297                b'H' => { result.push_str(&format!("{:02}", hour)); i += 2; }
298                b'M' => { result.push_str(&format!("{:02}", minute)); i += 2; }
299                b'S' => { result.push_str(&format!("{:02}", second)); i += 2; }
300                _ => { result.push('%'); i += 1; }
301            }
302        } else {
303            result.push(fbytes[i] as char);
304            i += 1;
305        }
306    }
307    result
308}
309
310/// Compute the difference `ts2 - ts1` in the specified unit.
311///
312/// Units: `"ms"` (milliseconds), `"s"` (seconds), `"min"` (minutes),
313/// `"h"` (hours), `"d"` (days).
314pub fn date_diff_units(ts1: i64, ts2: i64, unit: &str) -> Result<i64, String> {
315    let diff_ms = ts2 - ts1;
316    match unit {
317        "ms" => Ok(diff_ms),
318        "s" => Ok(diff_ms / MILLIS_PER_SECOND),
319        "min" => Ok(diff_ms / MILLIS_PER_MINUTE),
320        "h" => Ok(diff_ms / MILLIS_PER_HOUR),
321        "d" => Ok(diff_ms / MILLIS_PER_DAY),
322        _ => Err(format!("date_diff: unknown unit '{}', expected ms|s|min|h|d", unit)),
323    }
324}
325
326/// Add `amount` of the specified `unit` to a timestamp.
327///
328/// Units: `"ms"`, `"s"`, `"min"`, `"h"`, `"d"`.
329pub fn date_add_units(ts: i64, amount: i64, unit: &str) -> Result<i64, String> {
330    let millis = match unit {
331        "ms" => amount,
332        "s" => amount * MILLIS_PER_SECOND,
333        "min" => amount * MILLIS_PER_MINUTE,
334        "h" => amount * MILLIS_PER_HOUR,
335        "d" => amount * MILLIS_PER_DAY,
336        _ => return Err(format!("date_add: unknown unit '{}', expected ms|s|min|h|d", unit)),
337    };
338    Ok(ts + millis)
339}
340
341// Helper: parse exactly `n` digits from `bytes` starting at `pos`.
342fn parse_int_n(bytes: &[u8], pos: usize, n: usize) -> Option<(i64, usize)> {
343    if pos + n > bytes.len() { return None; }
344    let mut val: i64 = 0;
345    for i in 0..n {
346        let b = bytes[pos + i];
347        if !b.is_ascii_digit() { return None; }
348        val = val * 10 + (b - b'0') as i64;
349    }
350    Some((val, n))
351}
352
353// Helper: parse up to `max_digits` digits from `bytes` starting at `pos`.
354fn parse_int_max(bytes: &[u8], pos: usize, max_digits: usize) -> Option<(i64, usize)> {
355    let mut val: i64 = 0;
356    let mut count = 0;
357    while count < max_digits && pos + count < bytes.len() && bytes[pos + count].is_ascii_digit() {
358        val = val * 10 + (bytes[pos + count] - b'0') as i64;
359        count += 1;
360    }
361    if count == 0 { None } else { Some((val, count)) }
362}
363
364// ---------------------------------------------------------------------------
365// Tests
366// ---------------------------------------------------------------------------
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_epoch_origin() {
374        // 1970-01-01 00:00:00 = 0
375        let dt = datetime_from_parts(1970, 1, 1, 0, 0, 0);
376        assert_eq!(dt, 0);
377    }
378
379    #[test]
380    fn test_known_date() {
381        // 2000-01-01 00:00:00 UTC
382        let dt = datetime_from_parts(2000, 1, 1, 0, 0, 0);
383        assert_eq!(datetime_year(dt), 2000);
384        assert_eq!(datetime_month(dt), 1);
385        assert_eq!(datetime_day(dt), 1);
386        assert_eq!(datetime_hour(dt), 0);
387    }
388
389    #[test]
390    fn test_extraction_roundtrip() {
391        let dt = datetime_from_parts(2024, 6, 15, 14, 30, 45);
392        assert_eq!(datetime_year(dt), 2024);
393        assert_eq!(datetime_month(dt), 6);
394        assert_eq!(datetime_day(dt), 15);
395        assert_eq!(datetime_hour(dt), 14);
396        assert_eq!(datetime_minute(dt), 30);
397        assert_eq!(datetime_second(dt), 45);
398    }
399
400    #[test]
401    fn test_leap_year() {
402        assert!(is_leap_year(2000));
403        assert!(is_leap_year(2024));
404        assert!(!is_leap_year(1900));
405        assert!(!is_leap_year(2023));
406    }
407
408    #[test]
409    fn test_days_in_feb_leap() {
410        assert_eq!(days_in_month(2024, 2), 29);
411        assert_eq!(days_in_month(2023, 2), 28);
412    }
413
414    #[test]
415    fn test_format_iso8601() {
416        let dt = datetime_from_parts(2024, 3, 14, 9, 26, 53);
417        let s = datetime_format(dt);
418        assert_eq!(s, "2024-03-14T09:26:53Z");
419    }
420
421    #[test]
422    fn test_diff() {
423        let a = datetime_from_parts(2024, 1, 2, 0, 0, 0);
424        let b = datetime_from_parts(2024, 1, 1, 0, 0, 0);
425        assert_eq!(datetime_diff(a, b), MILLIS_PER_DAY);
426    }
427
428    #[test]
429    fn test_add_millis() {
430        let dt = datetime_from_parts(2024, 1, 1, 0, 0, 0);
431        let dt2 = datetime_add_millis(dt, MILLIS_PER_HOUR);
432        assert_eq!(datetime_hour(dt2), 1);
433    }
434
435    #[test]
436    fn test_format_epoch() {
437        assert_eq!(datetime_format(0), "1970-01-01T00:00:00Z");
438    }
439
440    #[test]
441    fn test_determinism() {
442        // Pure operations must produce identical results
443        let a = datetime_from_parts(2024, 12, 31, 23, 59, 59);
444        let b = datetime_from_parts(2024, 12, 31, 23, 59, 59);
445        assert_eq!(a, b);
446        assert_eq!(datetime_format(a), datetime_format(b));
447    }
448}