Skip to main content

ccalc_engine/
datetime.rs

1/// Pure-Rust datetime arithmetic helpers (no external crate required).
2///
3/// All datetimes are UTC Unix timestamps (seconds since 1970-01-01 00:00:00 UTC).
4/// The conversion algorithm is the one described by Howard Hinnant:
5/// <https://howardhinnant.github.io/date_algorithms.html>
6use std::time::{SystemTime, UNIX_EPOCH};
7
8// ── Civil ↔ days ──────────────────────────────────────────────────────────────
9
10/// Converts a proleptic Gregorian date (y, m, d) to a day count since the Unix epoch.
11/// Month is 1-based; day is 1-based.
12pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
13    let y = if m <= 2 { y - 1 } else { y };
14    let era: i64 = (if y >= 0 { y } else { y - 399 }) / 400;
15    let yoe = (y - era * 400) as u64;
16    let doy = (153 * (if m > 2 { m as u64 - 3 } else { m as u64 + 9 }) + 2) / 5 + d as u64 - 1;
17    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
18    era * 146097 + doe as i64 - 719468
19}
20
21/// Converts a day count since the Unix epoch to a proleptic Gregorian date.
22/// Returns `(year, month, day)` where month and day are 1-based.
23pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
24    let z = z + 719468;
25    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
26    let doe = (z - era * 146097) as u64;
27    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
28    let y = yoe as i64 + era * 400;
29    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
30    let mp = (5 * doy + 2) / 153;
31    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
32    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
33    let y = if m <= 2 { y + 1 } else { y };
34    (y, m, d)
35}
36
37// ── Timestamp ↔ components ────────────────────────────────────────────────────
38
39/// Decomposes a Unix timestamp (seconds) into calendar components.
40/// Returns `(year, month, day, hour, minute, second_f64)`.
41pub fn timestamp_to_civil(ts: f64) -> (i64, u32, u32, u32, u32, f64) {
42    let ts_i = ts.floor() as i64;
43    let sub_sec = ts - ts.floor();
44    let sod = ts_i.rem_euclid(86400) as u32;
45    let days = (ts_i - sod as i64) / 86400;
46    let h = sod / 3600;
47    let m = (sod % 3600) / 60;
48    let s = (sod % 60) as f64 + sub_sec;
49    let (y, mo, d) = civil_from_days(days);
50    (y, mo, d, h, m, s)
51}
52
53/// Assembles a Unix timestamp (seconds) from calendar components.
54pub fn civil_to_timestamp(y: i64, mo: u32, d: u32, h: u32, mi: u32, s: f64) -> f64 {
55    let days = days_from_civil(y, mo, d);
56    days as f64 * 86400.0 + h as f64 * 3600.0 + mi as f64 * 60.0 + s
57}
58
59// ── Parsing ────────────────────────────────────────────────────────────────────
60
61/// Parses an ISO 8601 date string: `yyyy-MM-dd` or `yyyy-MM-dd HH:mm:ss`.
62pub fn parse_iso8601(s: &str) -> Result<f64, String> {
63    let s = s.trim();
64    if s.len() == 10 {
65        // yyyy-MM-dd
66        let y = parse_component(s, 0, 4)?;
67        let mo = parse_component(s, 5, 7)?;
68        let d = parse_component(s, 8, 10)?;
69        if s.as_bytes().get(4) != Some(&b'-') || s.as_bytes().get(7) != Some(&b'-') {
70            return Err(format!("Invalid date format: '{s}'"));
71        }
72        let ts = civil_to_timestamp(y as i64, mo, d, 0, 0, 0.0);
73        return Ok(ts);
74    }
75    if s.len() == 19 {
76        // yyyy-MM-dd HH:mm:ss
77        let y = parse_component(s, 0, 4)?;
78        let mo = parse_component(s, 5, 7)?;
79        let d = parse_component(s, 8, 10)?;
80        let h = parse_component(s, 11, 13)?;
81        let mi = parse_component(s, 14, 16)?;
82        let sec = parse_component(s, 17, 19)?;
83        let sep_ok = s.as_bytes().get(4) == Some(&b'-')
84            && s.as_bytes().get(7) == Some(&b'-')
85            && (s.as_bytes().get(10) == Some(&b' ') || s.as_bytes().get(10) == Some(&b'T'))
86            && s.as_bytes().get(13) == Some(&b':')
87            && s.as_bytes().get(16) == Some(&b':');
88        if !sep_ok {
89            return Err(format!("Invalid datetime format: '{s}'"));
90        }
91        let ts = civil_to_timestamp(y as i64, mo, d, h, mi, sec as f64);
92        return Ok(ts);
93    }
94    Err(format!("Unsupported datetime format: '{s}'"))
95}
96
97fn parse_component(s: &str, start: usize, end: usize) -> Result<u32, String> {
98    s.get(start..end)
99        .and_then(|t| t.parse::<u32>().ok())
100        .ok_or_else(|| format!("Invalid numeric component in '{s}'"))
101}
102
103// ── Formatting ─────────────────────────────────────────────────────────────────
104
105/// Formats a Unix timestamp as `yyyy-MM-dd HH:mm:ss`.
106pub fn format_datetime(ts: f64) -> String {
107    if ts.is_nan() {
108        return "NaT".to_string();
109    }
110    let (y, mo, d, h, mi, s) = timestamp_to_civil(ts);
111    let sec_i = s.floor() as u32;
112    let sub = s - s.floor();
113    if sub > 1e-9 {
114        let ms = (sub * 1000.0).round() as u32;
115        format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{sec_i:02}.{ms:03}")
116    } else {
117        format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{sec_i:02}")
118    }
119}
120
121/// Formats a duration in seconds as `HH:MM:SS` or `Nd HH:MM:SS`.
122/// Adds `.mmm` suffix when sub-second precision is present.
123pub fn format_duration(secs: f64) -> String {
124    let abs = secs.abs();
125    let sign = if secs < 0.0 { "-" } else { "" };
126    let total_sec = abs.floor() as u64;
127    let sub = abs - abs.floor();
128    let h = total_sec / 3600;
129    let m = (total_sec % 3600) / 60;
130    let s = total_sec % 60;
131    let ms_suffix = if sub > 1e-9 {
132        format!(".{:03}", (sub * 1000.0).round() as u32)
133    } else {
134        String::new()
135    };
136    if h >= 24 {
137        let days = h / 24;
138        let rem_h = h % 24;
139        format!("{sign}{days}d {rem_h:02}:{m:02}:{s:02}{ms_suffix}")
140    } else {
141        format!("{sign}{h:02}:{m:02}:{s:02}{ms_suffix}")
142    }
143}
144
145const MONTH_ABBR: [&str; 12] = [
146    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
147];
148
149/// Formats a Unix timestamp using a pattern string.
150/// Supported tokens: `yyyy`, `MMM` (abbreviated month name), `MM`, `dd`, `HH`, `mm`, `ss`, `SSS`.
151pub fn format_datestr(ts: f64, pattern: &str) -> String {
152    if ts.is_nan() {
153        return "NaT".to_string();
154    }
155    let (y, mo, d, h, mi, s) = timestamp_to_civil(ts);
156    let sec_i = s.floor() as u32;
157    let ms = (s.fract() * 1000.0).round() as u32;
158    let mo_abbr = MONTH_ABBR
159        .get(mo.saturating_sub(1) as usize)
160        .copied()
161        .unwrap_or("???");
162    // Replace MMM before MM so the longer token wins.
163    pattern
164        .replace("yyyy", &format!("{y:04}"))
165        .replace("MMM", mo_abbr)
166        .replace("MM", &format!("{mo:02}"))
167        .replace("dd", &format!("{d:02}"))
168        .replace("HH", &format!("{h:02}"))
169        .replace("mm", &format!("{mi:02}"))
170        .replace("ss", &format!("{sec_i:02}"))
171        .replace("SSS", &format!("{ms:03}"))
172}
173
174/// Returns the current UTC time as a Unix timestamp.
175pub fn now_timestamp() -> f64 {
176    SystemTime::now()
177        .duration_since(UNIX_EPOCH)
178        .map(|d| d.as_secs_f64())
179        .unwrap_or(0.0)
180}
181
182/// Returns midnight today as a Unix timestamp.
183pub fn today_timestamp() -> f64 {
184    let ts = now_timestamp();
185    let days = (ts / 86400.0).floor();
186    days * 86400.0
187}
188
189// ── MATLAB datenum conversion ──────────────────────────────────────────────────
190
191/// MATLAB serial date number: days since 0000-Jan-00 (= 0000-Dec-31).
192/// Offset from Unix epoch: 1970-01-01 = datenum 719529.
193const MATLAB_EPOCH_DAYS: f64 = 719529.0;
194
195/// Converts a Unix timestamp to a MATLAB serial date number.
196pub fn to_datenum(ts: f64) -> f64 {
197    ts / 86400.0 + MATLAB_EPOCH_DAYS
198}
199
200/// Converts a MATLAB serial date number to a Unix timestamp.
201pub fn from_datenum(dn: f64) -> f64 {
202    (dn - MATLAB_EPOCH_DAYS) * 86400.0
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_epoch_roundtrip() {
211        // Unix epoch = 1970-01-01 00:00:00
212        let (y, mo, d, h, mi, s) = timestamp_to_civil(0.0);
213        assert_eq!((y, mo, d, h, mi), (1970, 1, 1, 0, 0));
214        assert!((s - 0.0).abs() < 1e-9);
215        let ts = civil_to_timestamp(1970, 1, 1, 0, 0, 0.0);
216        assert!((ts - 0.0).abs() < 1e-9);
217    }
218
219    #[test]
220    fn test_known_date() {
221        // 2024-01-15 09:30:00 UTC
222        let ts = civil_to_timestamp(2024, 1, 15, 9, 30, 0.0);
223        let (y, mo, d, h, mi, s) = timestamp_to_civil(ts);
224        assert_eq!((y, mo, d, h, mi), (2024, 1, 15, 9, 30));
225        assert!((s - 0.0).abs() < 1e-9);
226    }
227
228    #[test]
229    fn test_leap_year_boundary() {
230        // 2024 is a leap year; Feb 29 should exist
231        let ts = civil_to_timestamp(2024, 2, 29, 0, 0, 0.0);
232        let (y, mo, d, ..) = timestamp_to_civil(ts);
233        assert_eq!((y, mo, d), (2024, 2, 29));
234        // Day after leap day = March 1
235        let ts2 = ts + 86400.0;
236        let (y2, mo2, d2, ..) = timestamp_to_civil(ts2);
237        assert_eq!((y2, mo2, d2), (2024, 3, 1));
238    }
239
240    #[test]
241    fn test_iso8601_parsing() {
242        let ts = parse_iso8601("2024-01-15").unwrap();
243        let (y, mo, d, h, mi, _) = timestamp_to_civil(ts);
244        assert_eq!((y, mo, d, h, mi), (2024, 1, 15, 0, 0));
245
246        let ts2 = parse_iso8601("2024-01-15 09:30:00").unwrap();
247        let (y2, mo2, d2, h2, mi2, _) = timestamp_to_civil(ts2);
248        assert_eq!((y2, mo2, d2, h2, mi2), (2024, 1, 15, 9, 30));
249    }
250
251    #[test]
252    fn test_format_datetime() {
253        let ts = civil_to_timestamp(2024, 1, 15, 9, 30, 0.0);
254        assert_eq!(format_datetime(ts), "2024-01-15 09:30:00");
255        assert_eq!(format_datetime(f64::NAN), "NaT");
256    }
257
258    #[test]
259    fn test_format_duration() {
260        assert_eq!(format_duration(3600.0), "01:00:00");
261        assert_eq!(format_duration(90.0), "00:01:30");
262        assert_eq!(format_duration(86400.0 + 3600.0), "1d 01:00:00");
263        assert_eq!(format_duration(0.5), "00:00:00.500");
264    }
265
266    #[test]
267    fn test_datenum_roundtrip() {
268        // 1970-01-01 = datenum 719529
269        assert!((to_datenum(0.0) - 719529.0).abs() < 1e-9);
270        assert!((from_datenum(719529.0) - 0.0).abs() < 1e-9);
271    }
272}