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