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