Skip to main content

nautilus_core/
datetime.rs

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