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