Skip to main content

rushdb_types/
datetime.rs

1//! Canonical Date/Datetime handling backed by Chrono's ISO-8601 calendar
2//! implementation. `RushDB` stores instants as UTC epoch milliseconds.
3
4use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc};
5
6const DATE: &str = "%Y-%m-%d";
7const LOCAL_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%.f";
8const MS_PER_DAY: i64 = 86_400_000;
9
10/// Parse a date-only value, RFC 3339 timestamp, or timezone-less ISO local
11/// datetime (defined by `RushDB` as UTC) into epoch milliseconds.
12#[must_use]
13pub fn parse_datetime(value: &str) -> Option<i64> {
14    let value = value.trim();
15    // A signed year (`-0032-01-01T08:00:00Z`, ISO 8601 astronomical year
16    // numbering: year 0 is 1 BC) in the same layouts as an unsigned one.
17    // RFC 3339 has no signed years, so Chrono's RFC 3339 parser rejects
18    // them; without this a property of BC and AD dates split by sign into
19    // text and datetimes.
20    if let Some(ms) = parse_signed_year(value) {
21        return Some(ms);
22    }
23    if let Ok(datetime) = DateTime::parse_from_rfc3339(value) {
24        return Some(datetime.timestamp_millis());
25    }
26
27    // Chrono follows RFC 3339's uppercase `Z`; accepting lowercase `z`
28    // preserves the database's historical input convenience at the edge.
29    if let Some(prefix) = value.strip_suffix('z') {
30        let normalized = format!("{prefix}Z");
31        if let Ok(datetime) = DateTime::parse_from_rfc3339(&normalized) {
32            return Some(datetime.timestamp_millis());
33        }
34    }
35
36    if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATETIME) {
37        return Some(datetime.and_utc().timestamp_millis());
38    }
39    NaiveDate::parse_from_str(value, DATE)
40        .ok()?
41        .and_hms_opt(0, 0, 0)
42        .map(|datetime| datetime.and_utc().timestamp_millis())
43}
44
45/// [`parse_datetime`] for a `-` or `+` signed year: date-only, local
46/// datetime with seconds, or with `Z` or a `±HH:MM` offset; `T` separates
47/// date and time.
48fn parse_signed_year(value: &str) -> Option<i64> {
49    let (sign, rest) = value.split_at_checked(1)?;
50    let digits = rest.split('-').next()?;
51    if !matches!(sign, "-" | "+") || digits.len() < 4 || !digits.bytes().all(|b| b.is_ascii_digit())
52    {
53        return None;
54    }
55    if let Ok(datetime) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f%:z") {
56        return Some(datetime.timestamp_millis());
57    }
58    if let Some(local) = value.strip_suffix(['Z', 'z']) {
59        return NaiveDateTime::parse_from_str(local, LOCAL_DATETIME)
60            .ok()
61            .map(|datetime| datetime.and_utc().timestamp_millis());
62    }
63    if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATETIME) {
64        return Some(datetime.and_utc().timestamp_millis());
65    }
66    NaiveDate::parse_from_str(value, DATE)
67        .ok()?
68        .and_hms_opt(0, 0, 0)
69        .map(|datetime| datetime.and_utc().timestamp_millis())
70}
71
72/// Convert calendar components to epoch milliseconds, rejecting values that
73/// fall outside the representable range.
74///
75/// Returns `None` if any component is out of range or the combination is not a
76/// valid calendar instant.
77#[must_use]
78pub fn components_to_epoch_ms_checked(
79    year: i64,
80    month: i64,
81    day: i64,
82    hour: i64,
83    minute: i64,
84    second: i64,
85    millis: i64,
86) -> Option<i64> {
87    let year = i32::try_from(year).ok()?;
88    let month = u32::try_from(month).ok()?;
89    let day = u32::try_from(day).ok()?;
90    let hour = u32::try_from(hour).ok()?;
91    let minute = u32::try_from(minute).ok()?;
92    let second = u32::try_from(second).ok()?;
93    let millis = u32::try_from(millis).ok()?;
94    Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
95        .single()?
96        .with_nanosecond(millis.checked_mul(1_000_000)?)
97        .map(|datetime| datetime.timestamp_millis())
98}
99
100/// Build epoch milliseconds from validated calendar components.
101///
102/// Callers that accept untrusted components should use
103/// [`components_to_epoch_ms_checked`].
104///
105/// # Panics
106///
107/// Panics if the components do not form a valid calendar instant.
108#[must_use]
109pub fn components_to_epoch_ms(
110    year: i64,
111    month: i64,
112    day: i64,
113    hour: i64,
114    minute: i64,
115    second: i64,
116    millis: i64,
117) -> i64 {
118    components_to_epoch_ms_checked(year, month, day, hour, minute, second, millis)
119        .expect("valid datetime components")
120}
121
122/// Report whether a string parses as any supported datetime form.
123#[must_use]
124pub fn is_datetime_string(value: &str) -> bool {
125    parse_datetime(value).is_some()
126}
127
128/// Extract epoch milliseconds from a JSON number or datetime string.
129///
130/// Returns `None` for JSON values that are neither an integer nor a parseable
131/// datetime string.
132#[must_use]
133pub fn json_datetime_ms(value: &serde_json::Value) -> Option<i64> {
134    match value {
135        serde_json::Value::Number(number) => number.as_i64(),
136        serde_json::Value::String(value) => parse_datetime(value),
137        _ => None,
138    }
139}
140
141fn utc(ms: i64) -> DateTime<Utc> {
142    DateTime::from_timestamp_millis(ms).expect("epoch milliseconds in Chrono range")
143}
144
145/// Epoch milliseconds for the first instant of the calendar month containing
146/// `ms`.
147///
148/// # Panics
149///
150/// Panics if `ms` is outside the range representable by Chrono.
151#[must_use]
152pub fn start_of_month(ms: i64) -> i64 {
153    let datetime = utc(ms);
154    Utc.with_ymd_and_hms(datetime.year(), datetime.month(), 1, 0, 0, 0)
155        .single()
156        .expect("valid month start")
157        .timestamp_millis()
158}
159
160/// Epoch milliseconds for the first instant of the calendar year containing
161/// `ms`.
162///
163/// # Panics
164///
165/// Panics if `ms` is outside the range representable by Chrono.
166#[must_use]
167pub fn start_of_year(ms: i64) -> i64 {
168    let datetime = utc(ms);
169    Utc.with_ymd_and_hms(datetime.year(), 1, 1, 0, 0, 0)
170        .single()
171        .expect("valid year start")
172        .timestamp_millis()
173}
174
175/// Start of the 3-month calendar quarter containing `ms`
176/// (quarters start at months 1, 4, 7, 10).
177///
178/// # Panics
179///
180/// Panics if `ms` is outside the range representable by Chrono.
181#[must_use]
182pub fn start_of_quarter(ms: i64) -> i64 {
183    let datetime = utc(ms);
184    let quarter_start_month = ((datetime.month() - 1) / 3) * 3 + 1;
185    Utc.with_ymd_and_hms(datetime.year(), quarter_start_month, 1, 0, 0, 0)
186        .single()
187        .expect("valid quarter start")
188        .timestamp_millis()
189}
190
191/// Serialize epoch milliseconds as a date at midnight UTC, otherwise as an
192/// RFC-3339 UTC timestamp with millisecond precision.
193#[must_use]
194pub fn ms_to_datetime_string(ms: i64) -> String {
195    let datetime = utc(ms);
196    if ms.rem_euclid(MS_PER_DAY) == 0 {
197        datetime.format(DATE).to_string()
198    } else {
199        // RFC 3339 layout with milliseconds and `Z`; `%Y` also signs a year
200        // before 0000 or after 9999, which Chrono's RFC 3339 writer cannot.
201        datetime.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
202    }
203}
204
205/// Extract one calendar component from epoch milliseconds, using Chrono's
206/// field order: `0` year, `1` month, `2` day, `3` hour, `4` minute,
207/// `5` second, `6` millisecond, `7` microsecond, `8` nanosecond.
208///
209/// Returns `0` for unknown component indices.
210#[must_use]
211pub fn component_of(ms: i64, which: u8) -> i64 {
212    let datetime = utc(ms);
213    match which {
214        0 => i64::from(datetime.year()),
215        1 => i64::from(datetime.month()),
216        2 => i64::from(datetime.day()),
217        3 => i64::from(datetime.hour()),
218        4 => i64::from(datetime.minute()),
219        5 => i64::from(datetime.second()),
220        6 => i64::from(datetime.timestamp_subsec_millis()),
221        7 => i64::from(datetime.timestamp_subsec_micros()),
222        8 => i64::from(datetime.timestamp_subsec_nanos()),
223        _ => 0,
224    }
225}