Skip to main content

nautilus_core/
datetime.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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.
17use std::convert::TryFrom;
18
19use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, TimeDelta, Utc, Weekday};
20
21use crate::{UnixNanos, time::nanos_since_unix_epoch};
22
23/// Number of milliseconds in one second.
24pub const MILLISECONDS_IN_SECOND: u64 = 1_000;
25
26/// Number of nanoseconds in one second.
27pub const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
28
29/// Number of nanoseconds in one millisecond.
30pub const NANOSECONDS_IN_MILLISECOND: u64 = 1_000_000;
31
32/// Number of nanoseconds in one microsecond.
33pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
34
35/// Number of nanoseconds in one minute.
36pub const NANOSECONDS_IN_MINUTE: u64 = 60 * NANOSECONDS_IN_SECOND;
37
38/// Number of nanoseconds in one day.
39pub const NANOSECONDS_IN_DAY: u64 = 24 * 60 * NANOSECONDS_IN_MINUTE;
40
41/// Number of seconds in one minute.
42pub const SECONDS_IN_MINUTE: u64 = 60;
43
44/// Number of seconds in one hour.
45pub const SECONDS_IN_HOUR: u64 = 60 * SECONDS_IN_MINUTE;
46
47/// Number of seconds in one day.
48pub const SECONDS_IN_DAY: u64 = 24 * SECONDS_IN_HOUR;
49
50// Maximum finite seconds input that can be converted to nanoseconds without overflowing `u64`.
51#[expect(
52    clippy::cast_precision_loss,
53    reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
54)]
55const MAX_SECS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_SECOND as f64;
56// Maximum finite seconds input that can be converted to milliseconds without overflowing `u64`.
57#[expect(
58    clippy::cast_precision_loss,
59    reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
60)]
61const MAX_SECS_FOR_MILLIS: f64 = u64::MAX as f64 / MILLISECONDS_IN_SECOND as f64;
62// Maximum finite milliseconds input that can be converted to nanoseconds without overflowing `u64`.
63#[expect(
64    clippy::cast_precision_loss,
65    reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
66)]
67const MAX_MILLIS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MILLISECOND as f64;
68// Maximum finite microseconds input that can be converted to nanoseconds without overflowing `u64`.
69#[expect(
70    clippy::cast_precision_loss,
71    reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
72)]
73const MAX_MICROS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MICROSECOND as f64;
74
75// Compile-time checks for time constants to prevent accidental modification
76const _: () = {
77    assert!(NANOSECONDS_IN_SECOND == 1_000_000_000);
78    assert!(NANOSECONDS_IN_MILLISECOND == 1_000_000);
79    assert!(NANOSECONDS_IN_MICROSECOND == 1_000);
80    assert!(MILLISECONDS_IN_SECOND == 1_000);
81    assert!(NANOSECONDS_IN_SECOND == MILLISECONDS_IN_SECOND * NANOSECONDS_IN_MILLISECOND);
82    assert!(NANOSECONDS_IN_MILLISECOND == NANOSECONDS_IN_MICROSECOND * 1_000);
83    assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MILLISECOND == 1_000);
84    assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MICROSECOND == 1_000_000);
85    assert!(SECONDS_IN_MINUTE == 60);
86    assert!(SECONDS_IN_HOUR == 3_600);
87    assert!(SECONDS_IN_DAY == 86_400);
88    assert!(NANOSECONDS_IN_MINUTE == 60 * NANOSECONDS_IN_SECOND);
89    assert!(NANOSECONDS_IN_DAY == 24 * 60 * NANOSECONDS_IN_MINUTE);
90};
91
92#[inline]
93fn unix_nanos_to_datetime(unix_nanos: UnixNanos) -> anyhow::Result<DateTime<Utc>> {
94    let nanos_i64 = i64::try_from(unix_nanos.as_u64()).map_err(|_| {
95        anyhow::anyhow!(
96            "UnixNanos value {} exceeds maximum representable datetime (i64::MAX)",
97            unix_nanos.as_u64()
98        )
99    })?;
100    Ok(DateTime::from_timestamp_nanos(nanos_i64))
101}
102
103/// List of weekdays (Monday to Friday).
104pub const WEEKDAYS: [Weekday; 5] = [
105    Weekday::Mon,
106    Weekday::Tue,
107    Weekday::Wed,
108    Weekday::Thu,
109    Weekday::Fri,
110];
111
112/// Converts seconds to nanoseconds (ns).
113///
114/// # Errors
115///
116/// Returns an error if `secs` is non-finite or exceeds `MAX_SECS_FOR_NANOS`.
117#[expect(
118    clippy::cast_possible_truncation,
119    clippy::cast_sign_loss,
120    clippy::cast_precision_loss,
121    reason = "Intentional for unit conversion, may lose precision after clamping"
122)]
123pub fn secs_to_nanos(secs: f64) -> anyhow::Result<u64> {
124    anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
125    if secs <= 0.0 {
126        return Ok(0);
127    }
128    anyhow::ensure!(
129        secs <= MAX_SECS_FOR_NANOS,
130        "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_NANOS}"
131    );
132    let nanos = secs * NANOSECONDS_IN_SECOND as f64;
133    Ok(nanos.trunc() as u64)
134}
135
136/// Converts seconds to milliseconds (ms).
137///
138/// # Errors
139///
140/// Returns an error if `secs` is non-finite or exceeds `MAX_SECS_FOR_MILLIS`.
141#[expect(
142    clippy::cast_possible_truncation,
143    clippy::cast_sign_loss,
144    clippy::cast_precision_loss,
145    reason = "Intentional for unit conversion, may lose precision after clamping"
146)]
147pub fn secs_to_millis(secs: f64) -> anyhow::Result<u64> {
148    anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
149    if secs <= 0.0 {
150        return Ok(0);
151    }
152    anyhow::ensure!(
153        secs <= MAX_SECS_FOR_MILLIS,
154        "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_MILLIS}"
155    );
156    let millis = secs * MILLISECONDS_IN_SECOND as f64;
157    Ok(millis.trunc() as u64)
158}
159
160/// Converts seconds to nanoseconds (ns), panicking on invalid input.
161///
162/// This is a convenience wrapper around [`secs_to_nanos`] when the caller expects
163/// the input to be trusted and in-range.
164///
165/// # Panics
166///
167/// Panics if [`secs_to_nanos`] would return an error for `secs`.
168#[must_use]
169pub fn secs_to_nanos_unchecked(secs: f64) -> u64 {
170    secs_to_nanos(secs).expect("secs_to_nanos_unchecked: invalid or overflowing input")
171}
172
173/// Converts minutes to seconds.
174#[must_use]
175pub const fn mins_to_secs(mins: u64) -> u64 {
176    mins * SECONDS_IN_MINUTE
177}
178
179/// Converts minutes to nanoseconds.
180#[must_use]
181pub const fn mins_to_nanos(mins: u64) -> u64 {
182    mins * NANOSECONDS_IN_MINUTE
183}
184
185/// Converts milliseconds (ms) to nanoseconds (ns).
186///
187/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
188/// which may lose precision and drop negative values after clamping.
189///
190/// # Errors
191///
192/// Returns an error if `millis` is non-finite or exceeds `MAX_MILLIS_FOR_NANOS`.
193#[expect(
194    clippy::cast_possible_truncation,
195    clippy::cast_sign_loss,
196    clippy::cast_precision_loss,
197    reason = "Intentional for unit conversion, may lose precision after clamping"
198)]
199pub fn millis_to_nanos(millis: f64) -> anyhow::Result<u64> {
200    anyhow::ensure!(
201        millis.is_finite(),
202        "milliseconds must be finite, was {millis}"
203    );
204
205    if millis <= 0.0 {
206        return Ok(0);
207    }
208    anyhow::ensure!(
209        millis <= MAX_MILLIS_FOR_NANOS,
210        "milliseconds {millis} exceeds maximum representable value {MAX_MILLIS_FOR_NANOS}"
211    );
212    let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
213    Ok(nanos.trunc() as u64)
214}
215
216/// Converts milliseconds (ms) to nanoseconds (ns), panicking on invalid input.
217///
218/// # Panics
219///
220/// Panics if [`millis_to_nanos`] would return an error for `millis`.
221#[must_use]
222pub fn millis_to_nanos_unchecked(millis: f64) -> u64 {
223    millis_to_nanos(millis).expect("millis_to_nanos_unchecked: invalid or overflowing input")
224}
225
226/// Converts microseconds (μs) to nanoseconds (ns).
227///
228/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
229/// which may lose precision and drop negative values after clamping.
230///
231/// # Errors
232///
233/// Returns an error if `micros` is non-finite or exceeds `MAX_MICROS_FOR_NANOS`.
234#[expect(
235    clippy::cast_possible_truncation,
236    clippy::cast_sign_loss,
237    clippy::cast_precision_loss,
238    reason = "Intentional for unit conversion, may lose precision after clamping"
239)]
240pub fn micros_to_nanos(micros: f64) -> anyhow::Result<u64> {
241    anyhow::ensure!(
242        micros.is_finite(),
243        "microseconds must be finite, was {micros}"
244    );
245
246    if micros <= 0.0 {
247        return Ok(0);
248    }
249    anyhow::ensure!(
250        micros <= MAX_MICROS_FOR_NANOS,
251        "microseconds {micros} exceeds maximum representable value {MAX_MICROS_FOR_NANOS}"
252    );
253    let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
254    Ok(nanos.trunc() as u64)
255}
256
257/// Converts microseconds (μs) to nanoseconds (ns), panicking on invalid input.
258///
259/// # Panics
260///
261/// Panics if [`micros_to_nanos`] would return an error for `micros`.
262#[must_use]
263pub fn micros_to_nanos_unchecked(micros: f64) -> u64 {
264    micros_to_nanos(micros).expect("micros_to_nanos_unchecked: invalid or overflowing input")
265}
266
267/// Converts nanoseconds (ns) to seconds.
268///
269/// Casting u64 to f64 may lose precision for large values,
270/// but is acceptable when computing fractional seconds.
271#[expect(
272    clippy::cast_precision_loss,
273    reason = "Precision loss acceptable for time conversion"
274)]
275#[must_use]
276pub fn nanos_to_secs(nanos: u64) -> f64 {
277    let seconds = nanos / NANOSECONDS_IN_SECOND;
278    let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
279    (seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
280}
281
282/// Converts nanoseconds (ns) to milliseconds (ms).
283#[must_use]
284pub const fn nanos_to_millis(nanos: u64) -> u64 {
285    nanos / NANOSECONDS_IN_MILLISECOND
286}
287
288/// Converts nanoseconds (ns) to microseconds (μs).
289#[must_use]
290pub const fn nanos_to_micros(nanos: u64) -> u64 {
291    nanos / NANOSECONDS_IN_MICROSECOND
292}
293
294/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string.
295///
296/// Returns the raw nanosecond value as a string if it exceeds the representable
297/// datetime range (`i64::MAX`, approximately year 2262).
298#[inline]
299#[must_use]
300pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
301    match unix_nanos_to_datetime(unix_nanos) {
302        Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Nanos, true),
303        Err(_) => unix_nanos.as_u64().to_string(),
304    }
305}
306
307/// Converts an ISO 8601 (RFC 3339) format string to UNIX nanoseconds timestamp.
308///
309/// This function accepts various ISO 8601 formats including:
310/// - Full RFC 3339 with nanosecond precision: "2024-02-10T14:58:43.456789Z"
311/// - RFC 3339 without fractional seconds: "2024-02-10T14:58:43Z"
312/// - Simple date format: "2024-02-10" (interpreted as midnight UTC)
313///
314/// # Parameters
315///
316/// - `date_string`: The ISO 8601 formatted date string to parse
317///
318/// # Returns
319///
320/// Returns `Ok(UnixNanos)` if the string is successfully parsed, or an error if the format
321/// is invalid or the timestamp is out of range.
322///
323/// # Errors
324///
325/// Returns an error if:
326/// - The string format is not a valid ISO 8601 format
327/// - The timestamp is out of range for `UnixNanos`
328/// - The date/time values are invalid
329#[inline]
330pub fn iso8601_to_unix_nanos(date_string: &str) -> anyhow::Result<UnixNanos> {
331    date_string
332        .parse::<UnixNanos>()
333        .map_err(|e| anyhow::anyhow!("Failed to parse ISO 8601 string '{date_string}': {e}"))
334}
335
336/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string
337/// with millisecond precision.
338///
339/// Returns the raw nanosecond value as a string if it exceeds the representable
340/// datetime range (`i64::MAX`, approximately year 2262).
341#[inline]
342#[must_use]
343pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
344    match unix_nanos_to_datetime(unix_nanos) {
345        Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
346        Err(_) => unix_nanos.as_u64().to_string(),
347    }
348}
349
350/// Floor the given UNIX nanoseconds to the nearest microsecond.
351#[must_use]
352pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
353    (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
354}
355
356/// Calculates the last weekday (Mon-Fri) from the given `year`, `month` and `day`.
357///
358/// # Errors
359///
360/// Returns an error if the date is invalid.
361pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
362    let date =
363        NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
364    let current_weekday = date.weekday().number_from_monday();
365
366    // Calculate the offset in days for closest weekday (Mon-Fri)
367    let offset = i64::from(match current_weekday {
368        1..=5 => 0, // Monday to Friday, no adjustment needed
369        6 => 1,     // Saturday, adjust to previous Friday
370        _ => 2,     // Sunday, adjust to previous Friday
371    });
372    // Calculate last closest weekday
373    let last_closest = date - TimeDelta::days(offset);
374
375    // Convert to UNIX nanoseconds
376    let unix_timestamp_ns = last_closest
377        .and_hms_nano_opt(0, 0, 0, 0)
378        .ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
379
380    // Convert timestamp nanos safely from i64 to u64
381    let raw_ns = unix_timestamp_ns
382        .and_utc()
383        .timestamp_nanos_opt()
384        .ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))?;
385    let ns_u64 =
386        u64::try_from(raw_ns).map_err(|_| anyhow::anyhow!("Negative timestamp: {raw_ns}"))?;
387    Ok(UnixNanos::from(ns_u64))
388}
389
390/// Check whether the given UNIX nanoseconds timestamp is within the last 24 hours.
391///
392/// # Errors
393///
394/// Returns an error if the timestamp is invalid.
395pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
396    // Use the time seam so the comparison is deterministic under
397    // `simulation` + `cfg(madsim)` and we avoid a wall-clock call that
398    // would otherwise bypass the DST contract.
399    let timestamp_ns = timestamp_ns.as_u64();
400    let now_ns = nanos_since_unix_epoch();
401
402    // Future timestamps are not within the last 24 hours
403    if timestamp_ns > now_ns {
404        return Ok(false);
405    }
406
407    Ok(now_ns - timestamp_ns <= NANOSECONDS_IN_DAY)
408}
409
410/// Subtract `n` months from a chrono `DateTime<Utc>`.
411///
412/// # Errors
413///
414/// Returns an error if the resulting date would be invalid or out of range.
415pub fn subtract_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
416    match datetime.checked_sub_months(chrono::Months::new(n)) {
417        Some(result) => Ok(result),
418        None => anyhow::bail!("Failed to subtract {n} months from {datetime}"),
419    }
420}
421
422/// Add `n` months to a chrono `DateTime<Utc>`.
423///
424/// # Errors
425///
426/// Returns an error if the resulting date would be invalid or out of range.
427pub fn add_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
428    match datetime.checked_add_months(chrono::Months::new(n)) {
429        Some(result) => Ok(result),
430        None => anyhow::bail!("Failed to add {n} months to {datetime}"),
431    }
432}
433
434/// Subtract `n` months from a given UNIX nanoseconds timestamp.
435///
436/// # Errors
437///
438/// Returns an error if the resulting timestamp is out of range or invalid.
439#[expect(
440    clippy::cast_sign_loss,
441    reason = "explicit `if timestamp < 0` guard before the cast"
442)]
443pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
444    let datetime = unix_nanos_to_datetime(unix_nanos)?;
445    let result = subtract_n_months(datetime, n)?;
446    let timestamp = match result.timestamp_nanos_opt() {
447        Some(ts) => ts,
448        None => anyhow::bail!("Timestamp out of range after subtracting {n} months"),
449    };
450
451    if timestamp < 0 {
452        anyhow::bail!("Negative timestamp not allowed");
453    }
454
455    Ok(UnixNanos::from(timestamp as u64))
456}
457
458/// Add `n` months to a given UNIX nanoseconds timestamp.
459///
460/// # Errors
461///
462/// Returns an error if the resulting timestamp is out of range or invalid.
463#[expect(
464    clippy::cast_sign_loss,
465    reason = "explicit `if timestamp < 0` guard before the cast"
466)]
467pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
468    let datetime = unix_nanos_to_datetime(unix_nanos)?;
469    let result = add_n_months(datetime, n)?;
470    let timestamp = match result.timestamp_nanos_opt() {
471        Some(ts) => ts,
472        None => anyhow::bail!("Timestamp out of range after adding {n} months"),
473    };
474
475    if timestamp < 0 {
476        anyhow::bail!("Negative timestamp not allowed");
477    }
478
479    Ok(UnixNanos::from(timestamp as u64))
480}
481
482/// Add `n` years to a chrono `DateTime<Utc>`.
483///
484/// # Errors
485///
486/// Returns an error if the resulting date would be invalid or out of range.
487pub fn add_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
488    let months = n.checked_mul(12).ok_or_else(|| {
489        anyhow::anyhow!("Failed to add {n} years to {datetime}: month count overflow")
490    })?;
491
492    match datetime.checked_add_months(chrono::Months::new(months)) {
493        Some(result) => Ok(result),
494        None => anyhow::bail!("Failed to add {n} years to {datetime}"),
495    }
496}
497
498/// Subtract `n` years from a chrono `DateTime<Utc>`.
499///
500/// # Errors
501///
502/// Returns an error if the resulting date would be invalid or out of range.
503pub fn subtract_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
504    let months = n.checked_mul(12).ok_or_else(|| {
505        anyhow::anyhow!("Failed to subtract {n} years from {datetime}: month count overflow")
506    })?;
507
508    match datetime.checked_sub_months(chrono::Months::new(months)) {
509        Some(result) => Ok(result),
510        None => anyhow::bail!("Failed to subtract {n} years from {datetime}"),
511    }
512}
513
514/// Add `n` years to a given UNIX nanoseconds timestamp.
515///
516/// # Errors
517///
518/// Returns an error if the resulting timestamp is out of range or invalid.
519#[expect(
520    clippy::cast_sign_loss,
521    reason = "explicit `if timestamp < 0` guard before the cast"
522)]
523pub fn add_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
524    let datetime = unix_nanos_to_datetime(unix_nanos)?;
525    let result = add_n_years(datetime, n)?;
526    let timestamp = match result.timestamp_nanos_opt() {
527        Some(ts) => ts,
528        None => anyhow::bail!("Timestamp out of range after adding {n} years"),
529    };
530
531    if timestamp < 0 {
532        anyhow::bail!("Negative timestamp not allowed");
533    }
534
535    Ok(UnixNanos::from(timestamp as u64))
536}
537
538/// Subtract `n` years from a given UNIX nanoseconds timestamp.
539///
540/// # Errors
541///
542/// Returns an error if the resulting timestamp is out of range or invalid.
543#[expect(
544    clippy::cast_sign_loss,
545    reason = "explicit `if timestamp < 0` guard before the cast"
546)]
547pub fn subtract_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
548    let datetime = unix_nanos_to_datetime(unix_nanos)?;
549    let result = subtract_n_years(datetime, n)?;
550    let timestamp = match result.timestamp_nanos_opt() {
551        Some(ts) => ts,
552        None => anyhow::bail!("Timestamp out of range after subtracting {n} years"),
553    };
554
555    if timestamp < 0 {
556        anyhow::bail!("Negative timestamp not allowed");
557    }
558
559    Ok(UnixNanos::from(timestamp as u64))
560}
561
562/// Returns the last valid day of `(year, month)`.
563///
564/// Returns `None` if `month` is not in the range 1..=12.
565#[must_use]
566pub const fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
567    // Validate month range 1-12
568    if month < 1 || month > 12 {
569        return None;
570    }
571
572    // February leap-year logic
573    Some(match month {
574        2 => {
575            if is_leap_year(year) {
576                29
577            } else {
578                28
579            }
580        }
581        4 | 6 | 9 | 11 => 30,
582        _ => 31, // January, March, May, July, August, October, December
583    })
584}
585
586/// Basic leap-year check
587#[must_use]
588pub const fn is_leap_year(year: i32) -> bool {
589    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
590}
591
592/// Convert optional `DateTime` to optional `UnixNanos` timestamp.
593pub fn datetime_to_unix_nanos(value: Option<DateTime<Utc>>) -> Option<UnixNanos> {
594    value
595        .and_then(|dt| dt.timestamp_nanos_opt())
596        .and_then(|nanos| u64::try_from(nanos).ok())
597        .map(UnixNanos::from)
598}
599
600#[cfg(test)]
601#[expect(
602    clippy::float_cmp,
603    reason = "Exact float comparisons acceptable in tests"
604)]
605mod tests {
606    use chrono::{DateTime, TimeDelta, TimeZone, Timelike, Utc};
607    use rstest::rstest;
608
609    use super::*;
610
611    #[rstest]
612    #[case(0.0, 0)]
613    #[case(1.0, 1_000_000_000)]
614    #[case(1.1, 1_100_000_000)]
615    #[case(42.0, 42_000_000_000)]
616    #[case(0.000_123_5, 123_500)]
617    #[case(0.000_000_01, 10)]
618    #[case(0.000_000_001, 1)]
619    #[case(9.999_999_999, 9_999_999_999)]
620    fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
621        let result = secs_to_nanos(value).unwrap();
622        assert_eq!(result, expected);
623    }
624
625    #[rstest]
626    #[case(0.0, 0)]
627    #[case(1.0, 1_000)]
628    #[case(1.1, 1_100)]
629    #[case(42.0, 42_000)]
630    #[case(0.012_34, 12)]
631    #[case(0.001, 1)]
632    fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
633        let result = secs_to_millis(value).unwrap();
634        assert_eq!(result, expected);
635    }
636
637    #[rstest]
638    fn test_secs_to_nanos_unchecked_matches_checked() {
639        assert_eq!(secs_to_nanos_unchecked(1.1), secs_to_nanos(1.1).unwrap());
640    }
641
642    #[rstest]
643    fn test_secs_to_nanos_non_finite_errors() {
644        let err = secs_to_nanos(f64::NAN).unwrap_err();
645        assert!(err.to_string().contains("finite"));
646    }
647
648    #[rstest]
649    fn test_secs_to_nanos_overflow_errors() {
650        let err = secs_to_nanos(MAX_SECS_FOR_NANOS + 1.0).unwrap_err();
651        assert!(err.to_string().contains("exceeds"));
652    }
653
654    #[rstest]
655    fn test_secs_to_millis_non_finite_errors() {
656        let err = secs_to_millis(f64::INFINITY).unwrap_err();
657        assert!(err.to_string().contains("finite"));
658    }
659
660    #[rstest]
661    fn test_millis_to_nanos_overflow_errors() {
662        let err = millis_to_nanos(MAX_MILLIS_FOR_NANOS + 1.0).unwrap_err();
663        assert!(err.to_string().contains("exceeds"));
664    }
665
666    #[rstest]
667    fn test_millis_to_nanos_non_finite_errors() {
668        let err = millis_to_nanos(f64::NEG_INFINITY).unwrap_err();
669        assert!(err.to_string().contains("finite"));
670    }
671
672    #[rstest]
673    fn test_micros_to_nanos_non_finite_errors() {
674        let err = micros_to_nanos(f64::NAN).unwrap_err();
675        assert!(err.to_string().contains("finite"));
676    }
677
678    #[rstest]
679    #[case(0, 0)]
680    #[case(1, 60)]
681    #[case(5, 300)]
682    #[case(60, 3600)]
683    #[case(1440, 86400)]
684    fn test_mins_to_secs(#[case] mins: u64, #[case] expected: u64) {
685        assert_eq!(mins_to_secs(mins), expected);
686    }
687
688    #[rstest]
689    #[case(0, 0)]
690    #[case(1, 60_000_000_000)]
691    #[case(5, 300_000_000_000)]
692    #[case(60, 3_600_000_000_000)]
693    fn test_mins_to_nanos(#[case] mins: u64, #[case] expected: u64) {
694        assert_eq!(mins_to_nanos(mins), expected);
695    }
696
697    #[rstest]
698    fn test_micros_to_nanos_overflow_errors() {
699        // Use * 2.0 because + 1.0 doesn't change MAX_MICROS_FOR_NANOS due to f64 precision
700        let err = micros_to_nanos(MAX_MICROS_FOR_NANOS * 2.0).unwrap_err();
701        assert!(err.to_string().contains("exceeds"));
702    }
703
704    #[rstest]
705    fn test_secs_to_nanos_negative_infinity_errors() {
706        let result = secs_to_nanos(f64::NEG_INFINITY);
707        assert!(result.is_err());
708    }
709
710    #[rstest]
711    #[case(2024, 0)] // Month below range
712    #[case(2024, 13)] // Month above range
713    fn test_last_day_of_month_invalid_month(#[case] year: i32, #[case] month: u32) {
714        assert!(last_day_of_month(year, month).is_none());
715    }
716
717    #[rstest]
718    #[case(0.0, 0)]
719    #[case(1.0, 1_000_000)]
720    #[case(1.1, 1_100_000)]
721    #[case(42.0, 42_000_000)]
722    #[case(0.000_123_4, 123)]
723    #[case(0.000_01, 10)]
724    #[case(0.000_001, 1)]
725    #[case(9.999_999, 9_999_999)]
726    fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
727        let result = millis_to_nanos(value).unwrap();
728        assert_eq!(result, expected);
729    }
730
731    #[rstest]
732    fn test_millis_to_nanos_unchecked_matches_checked() {
733        assert_eq!(
734            millis_to_nanos_unchecked(1.1),
735            millis_to_nanos(1.1).unwrap()
736        );
737    }
738
739    #[rstest]
740    #[case(0.0, 0)]
741    #[case(1.0, 1_000)]
742    #[case(1.1, 1_100)]
743    #[case(42.0, 42_000)]
744    #[case(0.1234, 123)]
745    #[case(0.01, 10)]
746    #[case(0.001, 1)]
747    #[case(9.999, 9_999)]
748    fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
749        let result = micros_to_nanos(value).unwrap();
750        assert_eq!(result, expected);
751    }
752
753    #[rstest]
754    fn test_micros_to_nanos_unchecked_matches_checked() {
755        assert_eq!(
756            micros_to_nanos_unchecked(1.1),
757            micros_to_nanos(1.1).unwrap()
758        );
759    }
760
761    #[rstest]
762    #[case(0, 0.0)]
763    #[case(1, 1e-09)]
764    #[case(1_000_000_000, 1.0)]
765    #[case(42_897_123_111, 42.897_123_111)]
766    fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
767        let result = nanos_to_secs(value);
768        assert_eq!(result, expected);
769    }
770
771    #[rstest]
772    #[case(0, 0)]
773    #[case(1_000_000, 1)]
774    #[case(1_000_000_000, 1000)]
775    #[case(42_897_123_111, 42897)]
776    fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
777        let result = nanos_to_millis(value);
778        assert_eq!(result, expected);
779    }
780
781    #[rstest]
782    #[case(0, 0)]
783    #[case(1_000, 1)]
784    #[case(1_000_000_000, 1_000_000)]
785    #[case(42_897_123, 42_897)]
786    fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
787        let result = nanos_to_micros(value);
788        assert_eq!(result, expected);
789    }
790
791    #[rstest]
792    #[case(0, "1970-01-01T00:00:00.000000000Z")] // Unix epoch
793    #[case(1, "1970-01-01T00:00:00.000000001Z")] // 1 nanosecond
794    #[case(1_000, "1970-01-01T00:00:00.000001000Z")] // 1 microsecond
795    #[case(1_000_000, "1970-01-01T00:00:00.001000000Z")] // 1 millisecond
796    #[case(1_000_000_000, "1970-01-01T00:00:01.000000000Z")] // 1 second
797    #[case(1_702_857_600_000_000_000, "2023-12-18T00:00:00.000000000Z")] // Specific date
798    fn test_unix_nanos_to_iso8601(#[case] nanos: u64, #[case] expected: &str) {
799        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
800        assert_eq!(result, expected);
801    }
802
803    #[rstest]
804    #[case(0, "1970-01-01T00:00:00.000Z")] // Unix epoch
805    #[case(1_000_000, "1970-01-01T00:00:00.001Z")] // 1 millisecond
806    #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] // 1 second
807    #[case(1_702_857_600_123_456_789, "2023-12-18T00:00:00.123Z")] // With millisecond precision
808    fn test_unix_nanos_to_iso8601_millis(#[case] nanos: u64, #[case] expected: &str) {
809        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
810        assert_eq!(result, expected);
811    }
812
813    #[rstest]
814    #[case(2023, 12, 15, 1_702_598_400_000_000_000)] // Fri
815    #[case(2023, 12, 16, 1_702_598_400_000_000_000)] // Sat
816    #[case(2023, 12, 17, 1_702_598_400_000_000_000)] // Sun
817    #[case(2023, 12, 18, 1_702_857_600_000_000_000)] // Mon
818    fn test_last_closest_weekday_nanos_with_valid_date(
819        #[case] year: i32,
820        #[case] month: u32,
821        #[case] day: u32,
822        #[case] expected: u64,
823    ) {
824        let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
825        assert_eq!(result, expected);
826    }
827
828    #[rstest]
829    fn test_last_closest_weekday_nanos_with_invalid_date() {
830        let result = last_weekday_nanos(2023, 4, 31);
831        assert!(result.is_err());
832    }
833
834    #[rstest]
835    fn test_last_closest_weekday_nanos_with_nonexistent_date() {
836        let result = last_weekday_nanos(2023, 2, 30);
837        assert!(result.is_err());
838    }
839
840    #[rstest]
841    fn test_last_closest_weekday_nanos_with_invalid_conversion() {
842        let result = last_weekday_nanos(9999, 12, 31);
843        assert!(result.is_err());
844    }
845
846    #[rstest]
847    fn test_is_within_last_24_hours_when_now() {
848        let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
849        assert!(is_within_last_24_hours(UnixNanos::from(now_ns.cast_unsigned())).unwrap());
850    }
851
852    #[rstest]
853    fn test_is_within_last_24_hours_when_two_days_ago() {
854        let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
855            .timestamp_nanos_opt()
856            .unwrap();
857        assert!(!is_within_last_24_hours(UnixNanos::from(past_ns.cast_unsigned())).unwrap());
858    }
859
860    #[rstest]
861    fn test_is_within_last_24_hours_when_future() {
862        // Future timestamps should return false
863        let future_ns = (Utc::now() + TimeDelta::try_hours(1).unwrap())
864            .timestamp_nanos_opt()
865            .unwrap();
866        assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
867
868        // One day in the future should also return false
869        let future_ns = (Utc::now() + TimeDelta::try_days(1).unwrap())
870            .timestamp_nanos_opt()
871            .unwrap();
872        assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
873    }
874
875    #[rstest]
876    #[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
877    #[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
878    #[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
879    #[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
880    fn test_subtract_n_months(
881        #[case] input: DateTime<Utc>,
882        #[case] months: u32,
883        #[case] expected: DateTime<Utc>,
884    ) {
885        let result = subtract_n_months(input, months).unwrap();
886        assert_eq!(result, expected);
887    }
888
889    #[rstest]
890    #[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
891    #[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
892    #[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
893    #[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
894    fn test_add_n_months(
895        #[case] input: DateTime<Utc>,
896        #[case] months: u32,
897        #[case] expected: DateTime<Utc>,
898    ) {
899        let result = add_n_months(input, months).unwrap();
900        assert_eq!(result, expected);
901    }
902
903    #[rstest]
904    fn test_add_n_years_overflow() {
905        let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
906        let err = add_n_years(datetime, u32::MAX).unwrap_err();
907        assert!(err.to_string().contains("month count overflow"));
908    }
909
910    #[rstest]
911    fn test_subtract_n_years_overflow() {
912        let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
913        let err = subtract_n_years(datetime, u32::MAX).unwrap_err();
914        assert!(err.to_string().contains("month count overflow"));
915    }
916
917    #[rstest]
918    fn test_add_n_years_nanos_overflow() {
919        let nanos = UnixNanos::from(0);
920        let err = add_n_years_nanos(nanos, u32::MAX).unwrap_err();
921        assert!(err.to_string().contains("month count overflow"));
922    }
923
924    #[rstest]
925    #[case(2024, 2, 29)] // Leap year February
926    #[case(2023, 2, 28)] // Non-leap year February
927    #[case(2024, 12, 31)] // December
928    #[case(2023, 11, 30)] // November
929    fn test_last_day_of_month(#[case] year: i32, #[case] month: u32, #[case] expected: u32) {
930        let result = last_day_of_month(year, month).unwrap();
931        assert_eq!(result, expected);
932    }
933
934    #[rstest]
935    #[case(2024, true)] // Leap year divisible by 4
936    #[case(1900, false)] // Not leap year, divisible by 100 but not 400
937    #[case(2000, true)] // Leap year, divisible by 400
938    #[case(2023, false)] // Non-leap year
939    fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
940        let result = is_leap_year(year);
941        assert_eq!(result, expected);
942    }
943
944    #[rstest]
945    #[case("1970-01-01T00:00:00.000000000Z", 0)] // Unix epoch
946    #[case("1970-01-01T00:00:00.000000001Z", 1)] // 1 nanosecond
947    #[case("1970-01-01T00:00:00.001000000Z", 1_000_000)] // 1 millisecond
948    #[case("1970-01-01T00:00:01.000000000Z", 1_000_000_000)] // 1 second
949    #[case("2023-12-18T00:00:00.000000000Z", 1_702_857_600_000_000_000)] // Specific date
950    #[case("2024-02-10T14:58:43.456789Z", 1_707_577_123_456_789_000)] // RFC3339 with fractions
951    #[case("2024-02-10T14:58:43Z", 1_707_577_123_000_000_000)] // RFC3339 without fractions
952    #[case("2024-02-10", 1_707_523_200_000_000_000)] // Simple date format
953    fn test_iso8601_to_unix_nanos(#[case] input: &str, #[case] expected: u64) {
954        let result = iso8601_to_unix_nanos(input).unwrap();
955        assert_eq!(result.as_u64(), expected);
956    }
957
958    #[rstest]
959    #[case("invalid-date")] // Invalid format
960    #[case("2024-02-30")] // Invalid date
961    #[case("2024-13-01")] // Invalid month
962    #[case("not a timestamp")] // Random string
963    fn test_iso8601_to_unix_nanos_invalid(#[case] input: &str) {
964        let result = iso8601_to_unix_nanos(input);
965        assert!(result.is_err());
966    }
967
968    #[rstest]
969    fn test_iso8601_roundtrip() {
970        let original_nanos = UnixNanos::from(1_707_577_123_456_789_000);
971        let iso8601_string = unix_nanos_to_iso8601(original_nanos);
972        let parsed_nanos = iso8601_to_unix_nanos(&iso8601_string).unwrap();
973        assert_eq!(parsed_nanos, original_nanos);
974    }
975
976    #[rstest]
977    fn test_add_n_years_nanos_normal_case() {
978        // Test adding 1 year from 2020-01-01
979        let start = UnixNanos::from(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap());
980        let result = add_n_years_nanos(start, 1).unwrap();
981        let expected = UnixNanos::from(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap());
982        assert_eq!(result, expected);
983    }
984
985    #[rstest]
986    fn test_add_n_years_nanos_prevents_negative_timestamp() {
987        // Edge case: ensure we catch if somehow a negative timestamp would be produced
988        // This is a defensive check - in practice, adding years shouldn't produce negative
989        // timestamps from valid UnixNanos, but we verify the check is in place
990        let start = UnixNanos::from(0); // Epoch
991        // Adding years to epoch should never produce negative, but the check is there
992        let result = add_n_years_nanos(start, 1);
993        assert!(result.is_ok());
994    }
995
996    #[rstest]
997    fn test_datetime_to_unix_nanos_at_epoch() {
998        // Unix epoch (1970-01-01 00:00:00 UTC) should return 0 nanoseconds
999        let epoch = Utc.timestamp_opt(0, 0).unwrap();
1000        let result = datetime_to_unix_nanos(Some(epoch));
1001        assert_eq!(result, Some(UnixNanos::from(0)));
1002    }
1003
1004    #[rstest]
1005    fn test_datetime_to_unix_nanos_typical_datetime() {
1006        let dt = Utc
1007            .with_ymd_and_hms(2024, 1, 15, 13, 30, 45)
1008            .unwrap()
1009            .with_nanosecond(123_456_789)
1010            .unwrap();
1011        let result = datetime_to_unix_nanos(Some(dt));
1012
1013        // Expected: 1705325445123456789 nanoseconds
1014        assert!(result.is_some());
1015        assert_eq!(result.unwrap().as_u64(), 1_705_325_445_123_456_789);
1016    }
1017
1018    #[rstest]
1019    fn test_datetime_to_unix_nanos_before_epoch() {
1020        // Pre-epoch datetime (1969-12-31 23:59:59 UTC) should return None
1021        // because negative timestamps can't be converted to u64
1022        let before_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 59).unwrap();
1023        let result = datetime_to_unix_nanos(Some(before_epoch));
1024        assert_eq!(result, None);
1025    }
1026
1027    #[rstest]
1028    fn test_datetime_to_unix_nanos_one_second_after_epoch() {
1029        // 1970-01-01 00:00:01 UTC = 1_000_000_000 nanoseconds
1030        let dt = Utc.timestamp_opt(1, 0).unwrap();
1031        let result = datetime_to_unix_nanos(Some(dt));
1032        assert_eq!(result, Some(UnixNanos::from(1_000_000_000)));
1033    }
1034
1035    #[rstest]
1036    fn test_datetime_to_unix_nanos_with_subsecond_precision() {
1037        // Test with microseconds: 1970-01-01 00:00:00.000001 UTC
1038        let dt = Utc.timestamp_opt(0, 1_000).unwrap(); // 1 microsecond = 1000 nanos
1039        let result = datetime_to_unix_nanos(Some(dt));
1040        assert_eq!(result, Some(UnixNanos::from(1_000)));
1041    }
1042
1043    #[rstest]
1044    fn test_nanos_helpers_return_err_for_values_above_i64_max() {
1045        let large = UnixNanos::from(u64::MAX);
1046        assert!(subtract_n_months_nanos(large, 1).is_err());
1047        assert!(add_n_months_nanos(large, 1).is_err());
1048        assert!(add_n_years_nanos(large, 1).is_err());
1049        assert!(subtract_n_years_nanos(large, 1).is_err());
1050    }
1051}