nautilus_core/
datetime.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Common data and time functions.
17
18use std::time::{Duration, UNIX_EPOCH};
19
20use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, TimeDelta, Timelike, Utc, Weekday};
21
22use crate::UnixNanos;
23
24/// Number of milliseconds in one second.
25pub const MILLISECONDS_IN_SECOND: u64 = 1_000;
26
27/// Number of nanoseconds in one second.
28pub const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
29
30/// Number of nanoseconds in one millisecond.
31pub const NANOSECONDS_IN_MILLISECOND: u64 = 1_000_000;
32
33/// Number of nanoseconds in one microsecond.
34pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
35
36/// List of weekdays (Monday to Friday).
37pub const WEEKDAYS: [Weekday; 5] = [
38    Weekday::Mon,
39    Weekday::Tue,
40    Weekday::Wed,
41    Weekday::Thu,
42    Weekday::Fri,
43];
44
45/// Converts seconds to nanoseconds (ns).
46#[inline]
47#[no_mangle]
48pub extern "C" fn secs_to_nanos(secs: f64) -> u64 {
49    (secs * NANOSECONDS_IN_SECOND as f64) as u64
50}
51
52/// Converts seconds to milliseconds (ms).
53#[inline]
54#[no_mangle]
55pub extern "C" fn secs_to_millis(secs: f64) -> u64 {
56    (secs * MILLISECONDS_IN_SECOND as f64) as u64
57}
58
59/// Converts milliseconds (ms) to nanoseconds (ns).
60#[inline]
61#[no_mangle]
62pub extern "C" fn millis_to_nanos(millis: f64) -> u64 {
63    (millis * NANOSECONDS_IN_MILLISECOND as f64) as u64
64}
65
66/// Converts microseconds (μs) to nanoseconds (ns).
67#[inline]
68#[no_mangle]
69pub extern "C" fn micros_to_nanos(micros: f64) -> u64 {
70    (micros * NANOSECONDS_IN_MICROSECOND as f64) as u64
71}
72
73/// Converts nanoseconds (ns) to seconds.
74#[inline]
75#[no_mangle]
76pub extern "C" fn nanos_to_secs(nanos: u64) -> f64 {
77    nanos as f64 / NANOSECONDS_IN_SECOND as f64
78}
79
80/// Converts nanoseconds (ns) to milliseconds (ms).
81#[inline]
82#[no_mangle]
83pub const extern "C" fn nanos_to_millis(nanos: u64) -> u64 {
84    nanos / NANOSECONDS_IN_MILLISECOND
85}
86
87/// Converts nanoseconds (ns) to microseconds (μs).
88#[inline]
89#[no_mangle]
90pub const extern "C" fn nanos_to_micros(nanos: u64) -> u64 {
91    nanos / NANOSECONDS_IN_MICROSECOND
92}
93
94/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string.
95#[inline]
96#[must_use]
97pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
98    let dt = DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_nanos(unix_nanos.as_u64()));
99    dt.to_rfc3339_opts(SecondsFormat::Nanos, true)
100}
101
102/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string
103/// with millisecond precision.
104#[inline]
105#[must_use]
106pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
107    let dt = DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_nanos(unix_nanos.as_u64()));
108    dt.to_rfc3339_opts(SecondsFormat::Millis, true)
109}
110
111/// Floor the given UNIX nanoseconds to the nearest microsecond.
112#[must_use]
113pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
114    (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
115}
116
117/// Calculates the last weekday (Mon-Fri) from the given `year`, `month` and `day`.
118///
119/// # Errors
120///
121/// Returns an error if the date is invalid.
122pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
123    let date =
124        NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
125    let current_weekday = date.weekday().number_from_monday();
126
127    // Calculate the offset in days for closest weekday (Mon-Fri)
128    let offset = i64::from(match current_weekday {
129        1..=5 => 0, // Monday to Friday, no adjustment needed
130        6 => 1,     // Saturday, adjust to previous Friday
131        _ => 2,     // Sunday, adjust to previous Friday
132    });
133    // Calculate last closest weekday
134    let last_closest = date - TimeDelta::days(offset);
135
136    // Convert to UNIX nanoseconds
137    let unix_timestamp_ns = last_closest
138        .and_hms_nano_opt(0, 0, 0, 0)
139        .ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
140
141    Ok(UnixNanos::from(
142        unix_timestamp_ns
143            .and_utc()
144            .timestamp_nanos_opt()
145            .ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))? as u64,
146    ))
147}
148
149/// Check whether the given UNIX nanoseconds timestamp is within the last 24 hours.
150///
151/// # Errors
152///
153/// Returns an error if the timestamp is invalid.
154pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
155    let timestamp_ns = timestamp_ns.as_u64();
156    let seconds = timestamp_ns / NANOSECONDS_IN_SECOND;
157    let nanoseconds = (timestamp_ns % NANOSECONDS_IN_SECOND) as u32;
158    let timestamp = DateTime::from_timestamp(seconds as i64, nanoseconds)
159        .ok_or_else(|| anyhow::anyhow!("Invalid timestamp {timestamp_ns}"))?;
160    let now = Utc::now();
161
162    Ok(now.signed_duration_since(timestamp) <= TimeDelta::days(1))
163}
164
165/// Subtract `n` months from a chrono `DateTime<Utc>`.
166#[must_use]
167pub fn subtract_n_months(dt: DateTime<Utc>, n: isize) -> Option<DateTime<Utc>> {
168    // A naive approach:
169    //   1) Convert dt to y/m/d
170    //   2) Subtract n from the month
171    //   3) Adjust year if month < 1
172    //   4) Rebuild and keep day-of-month within valid range
173    //   5) Return new DateTime
174    let year = dt.year();
175    let month = dt.month() as isize; // 1..12
176    let day = dt.day(); // 1..31
177
178    let mut new_month = month - n;
179    let mut new_year = year;
180
181    // If subtracting months dips below 1, wrap around
182    while new_month <= 0 {
183        new_month += 12;
184        new_year -= 1;
185    }
186    // clamp day to something valid for new_year/new_month
187    let last_day_of_new_month = last_day_of_month(new_year, new_month as u32);
188    let new_day = day.min(last_day_of_new_month);
189
190    // Build a new Chrono NaiveDateTime
191    let new_date = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, new_day)?;
192    let new_naive_datetime =
193        new_date.and_hms_micro_opt(dt.hour(), dt.minute(), dt.second(), dt.nanosecond() / 1000)?;
194
195    // Convert back to UTC
196    let new_dt = DateTime::<Utc>::from_naive_utc_and_offset(new_naive_datetime, chrono::Utc);
197    Some(new_dt)
198}
199
200/// Add `n` months to a chrono `DateTime<Utc>`.
201#[must_use]
202pub fn add_n_months(dt: DateTime<Utc>, n: isize) -> Option<DateTime<Utc>> {
203    // Same approach but adding months
204    let year = dt.year();
205    let month = dt.month() as isize;
206    let day = dt.day();
207
208    let mut new_month = month + n;
209    let mut new_year = year;
210
211    // If months goes above 12, wrap around
212    while new_month > 12 {
213        new_month -= 12;
214        new_year += 1;
215    }
216    let last_day_of_new_month = last_day_of_month(new_year, new_month as u32);
217    let new_day = day.min(last_day_of_new_month);
218
219    let new_date = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, new_day)?;
220    let new_naive_datetime =
221        new_date.and_hms_micro_opt(dt.hour(), dt.minute(), dt.second(), dt.nanosecond() / 1000)?;
222
223    Some(DateTime::<Utc>::from_naive_utc_and_offset(
224        new_naive_datetime,
225        chrono::Utc,
226    ))
227}
228
229/// Returns the last valid day of `(year, month)`.
230#[must_use]
231pub const fn last_day_of_month(year: i32, month: u32) -> u32 {
232    // E.g., for February, check leap year logic
233    match month {
234        1 => 31,
235        2 => {
236            if is_leap_year(year) {
237                29
238            } else {
239                28
240            }
241        }
242        3 => 31,
243        4 => 30,
244        5 => 31,
245        6 => 30,
246        7 => 31,
247        8 => 31,
248        9 => 30,
249        10 => 31,
250        11 => 30,
251        12 => 31,
252        _ => 31, // fallback
253    }
254}
255
256/// Basic leap-year check
257#[must_use]
258pub const fn is_leap_year(year: i32) -> bool {
259    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
260}
261
262////////////////////////////////////////////////////////////////////////////////
263// Tests
264////////////////////////////////////////////////////////////////////////////////
265#[cfg(test)]
266mod tests {
267    use chrono::{DateTime, TimeDelta, TimeZone, Utc};
268    use rstest::rstest;
269
270    use super::*;
271
272    #[rstest]
273    #[case(0.0, 0)]
274    #[case(1.0, 1_000_000_000)]
275    #[case(1.1, 1_100_000_000)]
276    #[case(42.0, 42_000_000_000)]
277    #[case(0.000_123_5, 123_500)]
278    #[case(0.000_000_01, 10)]
279    #[case(0.000_000_001, 1)]
280    #[case(9.999_999_999, 9_999_999_999)]
281    fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
282        let result = secs_to_nanos(value);
283        assert_eq!(result, expected);
284    }
285
286    #[rstest]
287    #[case(0.0, 0)]
288    #[case(1.0, 1_000)]
289    #[case(1.1, 1_100)]
290    #[case(42.0, 42_000)]
291    #[case(0.012_34, 12)]
292    #[case(0.001, 1)]
293    fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
294        let result = secs_to_millis(value);
295        assert_eq!(result, expected);
296    }
297
298    #[rstest]
299    #[case(0.0, 0)]
300    #[case(1.0, 1_000_000)]
301    #[case(1.1, 1_100_000)]
302    #[case(42.0, 42_000_000)]
303    #[case(0.000_123_4, 123)]
304    #[case(0.000_01, 10)]
305    #[case(0.000_001, 1)]
306    #[case(9.999_999, 9_999_999)]
307    fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
308        let result = millis_to_nanos(value);
309        assert_eq!(result, expected);
310    }
311
312    #[rstest]
313    #[case(0.0, 0)]
314    #[case(1.0, 1_000)]
315    #[case(1.1, 1_100)]
316    #[case(42.0, 42_000)]
317    #[case(0.1234, 123)]
318    #[case(0.01, 10)]
319    #[case(0.001, 1)]
320    #[case(9.999, 9_999)]
321    fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
322        let result = micros_to_nanos(value);
323        assert_eq!(result, expected);
324    }
325
326    #[rstest]
327    #[case(0, 0.0)]
328    #[case(1, 1e-09)]
329    #[case(1_000_000_000, 1.0)]
330    #[case(42_897_123_111, 42.897_123_111)]
331    fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
332        let result = nanos_to_secs(value);
333        assert_eq!(result, expected);
334    }
335
336    #[rstest]
337    #[case(0, 0)]
338    #[case(1_000_000, 1)]
339    #[case(1_000_000_000, 1000)]
340    #[case(42_897_123_111, 42897)]
341    fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
342        let result = nanos_to_millis(value);
343        assert_eq!(result, expected);
344    }
345
346    #[rstest]
347    #[case(0, 0)]
348    #[case(1_000, 1)]
349    #[case(1_000_000_000, 1_000_000)]
350    #[case(42_897_123, 42_897)]
351    fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
352        let result = nanos_to_micros(value);
353        assert_eq!(result, expected);
354    }
355
356    #[rstest]
357    #[case(0, "1970-01-01T00:00:00.000000000Z")] // Unix epoch
358    #[case(1, "1970-01-01T00:00:00.000000001Z")] // 1 nanosecond
359    #[case(1_000, "1970-01-01T00:00:00.000001000Z")] // 1 microsecond
360    #[case(1_000_000, "1970-01-01T00:00:00.001000000Z")] // 1 millisecond
361    #[case(1_000_000_000, "1970-01-01T00:00:01.000000000Z")] // 1 second
362    #[case(1_702_857_600_000_000_000, "2023-12-18T00:00:00.000000000Z")] // Specific date
363    fn test_unix_nanos_to_iso8601(#[case] nanos: u64, #[case] expected: &str) {
364        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
365        assert_eq!(result, expected);
366    }
367
368    #[rstest]
369    #[case(0, "1970-01-01T00:00:00.000Z")] // Unix epoch
370    #[case(1_000_000, "1970-01-01T00:00:00.001Z")] // 1 millisecond
371    #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] // 1 second
372    #[case(1_702_857_600_123_456_789, "2023-12-18T00:00:00.123Z")] // With millisecond precision
373    fn test_unix_nanos_to_iso8601_millis(#[case] nanos: u64, #[case] expected: &str) {
374        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
375        assert_eq!(result, expected);
376    }
377
378    #[rstest]
379    #[case(2023, 12, 15, 1_702_598_400_000_000_000)] // Fri
380    #[case(2023, 12, 16, 1_702_598_400_000_000_000)] // Sat
381    #[case(2023, 12, 17, 1_702_598_400_000_000_000)] // Sun
382    #[case(2023, 12, 18, 1_702_857_600_000_000_000)] // Mon
383    fn test_last_closest_weekday_nanos_with_valid_date(
384        #[case] year: i32,
385        #[case] month: u32,
386        #[case] day: u32,
387        #[case] expected: u64,
388    ) {
389        let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
390        assert_eq!(result, expected);
391    }
392
393    #[rstest]
394    fn test_last_closest_weekday_nanos_with_invalid_date() {
395        let result = last_weekday_nanos(2023, 4, 31);
396        assert!(result.is_err());
397    }
398
399    #[rstest]
400    fn test_last_closest_weekday_nanos_with_nonexistent_date() {
401        let result = last_weekday_nanos(2023, 2, 30);
402        assert!(result.is_err());
403    }
404
405    #[rstest]
406    fn test_last_closest_weekday_nanos_with_invalid_conversion() {
407        let result = last_weekday_nanos(9999, 12, 31);
408        assert!(result.is_err());
409    }
410
411    #[rstest]
412    fn test_is_within_last_24_hours_when_now() {
413        let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
414        assert!(is_within_last_24_hours(UnixNanos::from(now_ns as u64)).unwrap());
415    }
416
417    #[rstest]
418    fn test_is_within_last_24_hours_when_two_days_ago() {
419        let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
420            .timestamp_nanos_opt()
421            .unwrap();
422        assert!(!is_within_last_24_hours(UnixNanos::from(past_ns as u64)).unwrap());
423    }
424
425    #[rstest]
426    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Leap year February
427    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 12, Utc.with_ymd_and_hms(2023, 3, 31, 12, 0, 0).unwrap())] // One year earlier
428    #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap())] // Wrapping to previous year
429    #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 2, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] // Multiple months back
430    fn test_subtract_n_months(
431        #[case] input: DateTime<Utc>,
432        #[case] months: isize,
433        #[case] expected: DateTime<Utc>,
434    ) {
435        let result = subtract_n_months(input, months);
436        assert_eq!(result, Some(expected));
437    }
438
439    #[rstest]
440    #[case(Utc.with_ymd_and_hms(2023, 2, 28, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 3, 28, 12, 0, 0).unwrap())] // Simple month addition
441    #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Leap year February
442    #[case(Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] // Wrapping to next year
443    #[case(Utc.with_ymd_and_hms(2023, 1, 31, 12, 0, 0).unwrap(), 13, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] // Crossing year boundary with multiple months
444    fn test_add_n_months(
445        #[case] input: DateTime<Utc>,
446        #[case] months: isize,
447        #[case] expected: DateTime<Utc>,
448    ) {
449        let result = add_n_months(input, months);
450        assert_eq!(result, Some(expected));
451    }
452
453    #[rstest]
454    #[case(2024, 2, 29)] // Leap year February
455    #[case(2023, 2, 28)] // Non-leap year February
456    #[case(2024, 12, 31)] // December
457    #[case(2023, 11, 30)] // November
458    fn test_last_day_of_month(#[case] year: i32, #[case] month: u32, #[case] expected: u32) {
459        let result = last_day_of_month(year, month);
460        assert_eq!(result, expected);
461    }
462
463    #[rstest]
464    #[case(2024, true)] // Leap year divisible by 4
465    #[case(1900, false)] // Not leap year, divisible by 100 but not 400
466    #[case(2000, true)] // Leap year, divisible by 400
467    #[case(2023, false)] // Non-leap year
468    fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
469        let result = is_leap_year(year);
470        assert_eq!(result, expected);
471    }
472}