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