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