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 writers 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 milliseconds (ms) to nanoseconds (ns).
297///
298/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
299/// which may lose precision and drop negative values after clamping.
300///
301/// # Errors
302///
303/// Returns an error if `millis` is non-finite or cannot be represented as `u64` nanoseconds.
304#[expect(
305    clippy::cast_possible_truncation,
306    clippy::cast_sign_loss,
307    clippy::cast_precision_loss,
308    reason = "Intentional for unit conversion, may lose precision after clamping"
309)]
310pub fn millis_to_nanos(millis: f64) -> anyhow::Result<u64> {
311    anyhow::ensure!(
312        millis.is_finite(),
313        "milliseconds must be finite, was {millis}"
314    );
315
316    if millis <= 0.0 {
317        return Ok(0);
318    }
319    let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
320    anyhow::ensure!(
321        nanos < U64_UPPER_BOUND_F64,
322        "milliseconds {millis} is out of range for `u64` nanoseconds"
323    );
324    Ok(nanos.trunc() as u64)
325}
326
327/// Converts milliseconds (ms) to nanoseconds (ns), panicking on invalid input.
328///
329/// # Panics
330///
331/// Panics if [`millis_to_nanos`] would return an error for `millis`.
332#[must_use]
333pub fn millis_to_nanos_unchecked(millis: f64) -> u64 {
334    millis_to_nanos(millis).expect("millis_to_nanos_unchecked: invalid or overflowing input")
335}
336
337/// Converts microseconds (μs) to nanoseconds (ns).
338///
339/// Casting f64 to u64 by truncating the fractional part is intentional for unit conversion,
340/// which may lose precision and drop negative values after clamping.
341///
342/// # Errors
343///
344/// Returns an error if `micros` is non-finite or cannot be represented as `u64` nanoseconds.
345#[expect(
346    clippy::cast_possible_truncation,
347    clippy::cast_sign_loss,
348    clippy::cast_precision_loss,
349    reason = "Intentional for unit conversion, may lose precision after clamping"
350)]
351pub fn micros_to_nanos(micros: f64) -> anyhow::Result<u64> {
352    anyhow::ensure!(
353        micros.is_finite(),
354        "microseconds must be finite, was {micros}"
355    );
356
357    if micros <= 0.0 {
358        return Ok(0);
359    }
360    let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
361    anyhow::ensure!(
362        nanos < U64_UPPER_BOUND_F64,
363        "microseconds {micros} is out of range for `u64` nanoseconds"
364    );
365    Ok(nanos.trunc() as u64)
366}
367
368/// Converts microseconds (μs) to nanoseconds (ns), panicking on invalid input.
369///
370/// # Panics
371///
372/// Panics if [`micros_to_nanos`] would return an error for `micros`.
373#[must_use]
374pub fn micros_to_nanos_unchecked(micros: f64) -> u64 {
375    micros_to_nanos(micros).expect("micros_to_nanos_unchecked: invalid or overflowing input")
376}
377
378/// Converts nanoseconds (ns) to seconds.
379///
380/// Casting u64 to f64 may lose precision for large values,
381/// but is acceptable when computing fractional seconds.
382#[expect(
383    clippy::cast_precision_loss,
384    reason = "Precision loss acceptable for time conversion"
385)]
386#[must_use]
387pub fn nanos_to_secs(nanos: u64) -> f64 {
388    let seconds = nanos / NANOSECONDS_IN_SECOND;
389    let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
390    (seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
391}
392
393/// Converts nanoseconds (ns) to milliseconds (ms).
394#[must_use]
395pub const fn nanos_to_millis(nanos: u64) -> u64 {
396    nanos / NANOSECONDS_IN_MILLISECOND
397}
398
399/// Converts nanoseconds (ns) to microseconds (μs).
400#[must_use]
401pub const fn nanos_to_micros(nanos: u64) -> u64 {
402    nanos / NANOSECONDS_IN_MICROSECOND
403}
404
405/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string.
406///
407/// All [`UnixNanos`] values are representable by this formatter.
408#[inline]
409#[must_use]
410pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
411    let parts = split_unix_nanos(unix_nanos);
412
413    let mut out = String::with_capacity(30);
414    push_iso8601_prefix(
415        &mut out,
416        parts.year,
417        parts.month,
418        parts.day,
419        parts.hour,
420        parts.minute,
421        parts.second,
422    );
423    push_9_digits(&mut out, parts.subsec_nanos);
424    out.push('Z');
425    out
426}
427
428/// Converts an ISO 8601 (RFC 3339) format string to UNIX nanoseconds timestamp.
429///
430/// This function accepts various ISO 8601 formats including:
431/// - Full RFC 3339 with nanosecond precision: "2024-02-10T14:58:43.456789Z"
432/// - RFC 3339 without fractional seconds: "2024-02-10T14:58:43Z"
433/// - Simple date format: "2024-02-10" (interpreted as midnight UTC)
434///
435/// # Parameters
436///
437/// - `date_string`: The ISO 8601 formatted date string to parse
438///
439/// # Returns
440///
441/// Returns `Ok(UnixNanos)` if the string is successfully parsed, or an error if the format
442/// is invalid or the timestamp is out of range.
443///
444/// # Errors
445///
446/// Returns an error if:
447/// - The string format is not a valid ISO 8601 format
448/// - The timestamp is out of range for `UnixNanos`
449/// - The date/time values are invalid
450#[inline]
451pub fn iso8601_to_unix_nanos(date_string: &str) -> anyhow::Result<UnixNanos> {
452    date_string
453        .parse::<UnixNanos>()
454        .map_err(|e| anyhow::anyhow!("Failed to parse ISO 8601 string '{date_string}': {e}"))
455}
456
457/// Converts a UNIX nanoseconds timestamp to an ISO 8601 (RFC 3339) format string
458/// with millisecond precision.
459///
460/// All [`UnixNanos`] values are representable by this formatter.
461#[inline]
462#[must_use]
463pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
464    let parts = split_unix_nanos(unix_nanos);
465
466    let mut out = String::with_capacity(24);
467    push_iso8601_prefix(
468        &mut out,
469        parts.year,
470        parts.month,
471        parts.day,
472        parts.hour,
473        parts.minute,
474        parts.second,
475    );
476    push_3_digits(
477        &mut out,
478        parts.subsec_nanos / NANOSECONDS_IN_MILLISECOND_U32,
479    );
480    out.push('Z');
481    out
482}
483
484/// Floor the given UNIX nanoseconds to the nearest microsecond.
485#[must_use]
486pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
487    (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
488}
489
490/// Calculates the last weekday (Mon-Fri) from the given `year`, `month`, and `day`.
491///
492/// # Errors
493///
494/// Returns an error if the date is invalid.
495pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
496    let date = Date::new(
497        i16::try_from(year).map_err(|_| anyhow::anyhow!("Invalid date"))?,
498        i8::try_from(month).map_err(|_| anyhow::anyhow!("Invalid date"))?,
499        i8::try_from(day).map_err(|_| anyhow::anyhow!("Invalid date"))?,
500    )
501    .map_err(|_| anyhow::anyhow!("Invalid date"))?;
502    let current_weekday = date.weekday().to_monday_one_offset();
503
504    // Calculate the offset in days for closest weekday (Mon-Fri)
505    let offset = match current_weekday {
506        1..=5 => 0, // Monday to Friday, no adjustment needed
507        6 => 1,     // Saturday, adjust to previous Friday
508        _ => 2,     // Sunday, adjust to previous Friday
509    };
510    // Calculate last closest weekday
511    let last_closest = date.checked_sub(Span::new().days(offset))?;
512
513    // Convert to UNIX nanoseconds
514    let unix_timestamp_ns = last_closest
515        .at(0, 0, 0, 0)
516        .to_zoned(TimeZone::UTC)?
517        .timestamp()
518        .as_nanosecond();
519
520    let ns_u64 = u64::try_from(unix_timestamp_ns)
521        .map_err(|_| anyhow::anyhow!("Negative timestamp: {unix_timestamp_ns}"))?;
522    Ok(UnixNanos::from(ns_u64))
523}
524
525/// Check whether the given UNIX nanoseconds timestamp is within the last 24 hours.
526///
527/// # Errors
528///
529/// Returns an error if the timestamp is invalid.
530pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
531    // Use the time seam so the comparison is deterministic under
532    // `simulation` + `cfg(madsim)` and we avoid a wall-clock call that
533    // would otherwise bypass the DST contract.
534    let timestamp_ns = timestamp_ns.as_u64();
535    let now_ns = nanos_since_unix_epoch();
536
537    // Future timestamps are not within the last 24 hours
538    if timestamp_ns > now_ns {
539        return Ok(false);
540    }
541
542    Ok(now_ns - timestamp_ns <= NANOSECONDS_IN_DAY)
543}
544
545fn shift_months(datetime: Timestamp, months: i64) -> anyhow::Result<Timestamp> {
546    let span = Span::new().try_months(months)?;
547    let result = datetime.to_zoned(TimeZone::UTC).checked_add(span)?;
548    Ok(result.timestamp())
549}
550
551/// Subtract `n` months from a Jiff [`Timestamp`].
552///
553/// # Errors
554///
555/// Returns an error if the resulting date would be invalid or out of range.
556pub fn subtract_n_months(datetime: Timestamp, n: u32) -> anyhow::Result<Timestamp> {
557    shift_months(datetime, -i64::from(n))
558        .map_err(|_| anyhow::anyhow!("Failed to subtract {n} months from {datetime}"))
559}
560
561/// Add `n` months to a Jiff [`Timestamp`].
562///
563/// # Errors
564///
565/// Returns an error if the resulting date would be invalid or out of range.
566pub fn add_n_months(datetime: Timestamp, n: u32) -> anyhow::Result<Timestamp> {
567    shift_months(datetime, i64::from(n))
568        .map_err(|_| anyhow::anyhow!("Failed to add {n} months to {datetime}"))
569}
570
571/// Subtract `n` months from a given UNIX nanoseconds timestamp.
572///
573/// # Errors
574///
575/// Returns an error if the resulting timestamp is out of range or invalid.
576pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
577    let datetime = unix_nanos.to_datetime_utc();
578    let result = subtract_n_months(datetime, n)?;
579    let timestamp = result.as_nanosecond();
580
581    let nanos =
582        u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
583    Ok(UnixNanos::from(nanos))
584}
585
586/// Add `n` months to a given UNIX nanoseconds timestamp.
587///
588/// # Errors
589///
590/// Returns an error if the resulting timestamp is out of range or invalid.
591pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
592    let datetime = unix_nanos.to_datetime_utc();
593    let result = add_n_months(datetime, n)?;
594    let timestamp = result.as_nanosecond();
595
596    let nanos =
597        u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
598    Ok(UnixNanos::from(nanos))
599}
600
601/// Add `n` years to a Jiff [`Timestamp`].
602///
603/// # Errors
604///
605/// Returns an error if the resulting date would be invalid or out of range.
606pub fn add_n_years(datetime: Timestamp, n: u32) -> anyhow::Result<Timestamp> {
607    let months = n.checked_mul(12).ok_or_else(|| {
608        anyhow::anyhow!("Failed to add {n} years to {datetime}: month count overflow")
609    })?;
610
611    shift_months(datetime, i64::from(months))
612        .map_err(|_| anyhow::anyhow!("Failed to add {n} years to {datetime}"))
613}
614
615/// Subtract `n` years from a Jiff [`Timestamp`].
616///
617/// # Errors
618///
619/// Returns an error if the resulting date would be invalid or out of range.
620pub fn subtract_n_years(datetime: Timestamp, n: u32) -> anyhow::Result<Timestamp> {
621    let months = n.checked_mul(12).ok_or_else(|| {
622        anyhow::anyhow!("Failed to subtract {n} years from {datetime}: month count overflow")
623    })?;
624
625    shift_months(datetime, -i64::from(months))
626        .map_err(|_| anyhow::anyhow!("Failed to subtract {n} years from {datetime}"))
627}
628
629/// Add `n` years to a given UNIX nanoseconds timestamp.
630///
631/// # Errors
632///
633/// Returns an error if the resulting timestamp is out of range or invalid.
634pub fn add_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
635    let datetime = unix_nanos.to_datetime_utc();
636    let result = add_n_years(datetime, n)?;
637    let timestamp = result.as_nanosecond();
638
639    let nanos =
640        u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
641    Ok(UnixNanos::from(nanos))
642}
643
644/// Subtract `n` years from a given UNIX nanoseconds timestamp.
645///
646/// # Errors
647///
648/// Returns an error if the resulting timestamp is out of range or invalid.
649pub fn subtract_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
650    let datetime = unix_nanos.to_datetime_utc();
651    let result = subtract_n_years(datetime, n)?;
652    let timestamp = result.as_nanosecond();
653
654    let nanos =
655        u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
656    Ok(UnixNanos::from(nanos))
657}
658
659/// Convert an optional [`Timestamp`] to an optional [`UnixNanos`] timestamp.
660pub fn datetime_to_unix_nanos(value: Option<Timestamp>) -> Option<UnixNanos> {
661    value
662        .map(Timestamp::as_nanosecond)
663        .and_then(|nanos| u64::try_from(nanos).ok())
664        .map(UnixNanos::from)
665}
666
667/// Converts a `Timestamp` to `UnixNanos`.
668///
669/// Unlike `UnixNanos::from(Timestamp)` which panics, this returns an error.
670///
671/// # Errors
672///
673/// Returns an error if the timestamp is before the UNIX epoch or out of range for `UnixNanos`.
674pub fn try_datetime_to_unix_nanos(value: Timestamp) -> anyhow::Result<UnixNanos> {
675    let nanos = value.as_nanosecond();
676
677    if nanos < 0 {
678        anyhow::bail!("DateTime timestamp cannot be negative: {nanos}");
679    }
680    let nanos = u64::try_from(nanos)
681        .map_err(|_| anyhow::anyhow!("DateTime timestamp out of range for UnixNanos: {nanos}"))?;
682
683    Ok(UnixNanos::from(nanos))
684}
685
686#[cfg(test)]
687// `allow` not `expect`: nightly clippy does not fire `float_cmp` inside `assert_eq!`
688#[allow(
689    clippy::float_cmp,
690    reason = "Exact float comparisons acceptable in tests"
691)]
692mod tests {
693    use jiff::SignedDuration;
694    use proptest::prelude::*;
695    use rstest::rstest;
696
697    use super::*;
698
699    fn timestamp(value: &str) -> Timestamp {
700        value.parse().unwrap()
701    }
702
703    #[rstest]
704    #[case(0.0, 0)]
705    #[case(1.0, 1_000_000_000)]
706    #[case(1.1, 1_100_000_000)]
707    #[case(42.0, 42_000_000_000)]
708    #[case(0.000_123_5, 123_500)]
709    #[case(0.000_000_01, 10)]
710    #[case(0.000_000_001, 1)]
711    #[case(9.999_999_999, 9_999_999_999)]
712    fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
713        let result = secs_to_nanos(value).unwrap();
714        assert_eq!(result, expected);
715    }
716
717    #[rstest]
718    #[case(0.0, 0)]
719    #[case(1.0, 1_000)]
720    #[case(1.1, 1_100)]
721    #[case(42.0, 42_000)]
722    #[case(0.012_34, 12)]
723    #[case(0.001, 1)]
724    fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
725        let result = secs_to_millis(value).unwrap();
726        assert_eq!(result, expected);
727    }
728
729    #[rstest]
730    fn test_secs_to_nanos_unchecked_matches_checked() {
731        assert_eq!(secs_to_nanos_unchecked(1.1), secs_to_nanos(1.1).unwrap());
732    }
733
734    #[rstest]
735    fn test_secs_to_nanos_non_finite_errors() {
736        let err = secs_to_nanos(f64::NAN).unwrap_err();
737        assert!(err.to_string().contains("finite"));
738    }
739
740    #[rstest]
741    fn test_secs_to_millis_non_finite_errors() {
742        let err = secs_to_millis(f64::INFINITY).unwrap_err();
743        assert!(err.to_string().contains("finite"));
744    }
745
746    #[rstest]
747    fn test_millis_to_nanos_non_finite_errors() {
748        let err = millis_to_nanos(f64::NEG_INFINITY).unwrap_err();
749        assert!(err.to_string().contains("finite"));
750    }
751
752    #[rstest]
753    fn test_micros_to_nanos_non_finite_errors() {
754        let err = micros_to_nanos(f64::NAN).unwrap_err();
755        assert!(err.to_string().contains("finite"));
756    }
757
758    #[rstest]
759    #[case(0, 0)]
760    #[case(1, 60)]
761    #[case(5, 300)]
762    #[case(60, 3600)]
763    #[case(1440, 86400)]
764    fn test_mins_to_secs(#[case] mins: u64, #[case] expected: u64) {
765        assert_eq!(mins_to_secs(mins), expected);
766    }
767
768    #[rstest]
769    #[case(
770        checked_mins_to_secs,
771        307_445_734_561_825_860,
772        18_446_744_073_709_551_600
773    )]
774    fn test_checked_minutes_conversion_boundary(
775        #[case] convert: fn(u64) -> Option<u64>,
776        #[case] max: u64,
777        #[case] expected: u64,
778    ) {
779        assert_eq!(convert(max), Some(expected));
780        assert_eq!(convert(max + 1), None);
781    }
782
783    #[rstest]
784    #[should_panic(expected = "minutes to seconds conversion overflow")]
785    fn test_mins_to_secs_overflow_panics() {
786        let _ = mins_to_secs(307_445_734_561_825_861);
787    }
788
789    #[rstest]
790    #[case(
791        secs_to_nanos,
792        18_446_744_073.709_553,
793        18_446_744_073.709_55,
794        18_446_744_073_709_549_568
795    )]
796    #[case(
797        secs_to_millis,
798        18_446_744_073_709_550.0,
799        18_446_744_073_709_548.0,
800        18_446_744_073_709_547_520
801    )]
802    #[case(
803        millis_to_nanos,
804        18_446_744_073_709.55,
805        18_446_744_073_709.547,
806        18_446_744_073_709_547_520
807    )]
808    #[case(
809        micros_to_nanos,
810        18_446_744_073_709_550.0,
811        18_446_744_073_709_548.0,
812        18_446_744_073_709_547_520
813    )]
814    fn test_float_conversion_u64_boundary(
815        #[case] convert: fn(f64) -> anyhow::Result<u64>,
816        #[case] invalid: f64,
817        #[case] previous: f64,
818        #[case] expected: u64,
819    ) {
820        let err = convert(invalid).unwrap_err();
821        assert!(err.to_string().contains("out of range"));
822        assert_eq!(convert(previous).unwrap(), expected);
823    }
824
825    #[rstest]
826    fn test_secs_to_nanos_negative_infinity_errors() {
827        let result = secs_to_nanos(f64::NEG_INFINITY);
828        assert!(result.is_err());
829    }
830
831    #[rstest]
832    #[case(0.0, 0)]
833    #[case(1.0, 1_000_000)]
834    #[case(1.1, 1_100_000)]
835    #[case(42.0, 42_000_000)]
836    #[case(0.000_123_4, 123)]
837    #[case(0.000_01, 10)]
838    #[case(0.000_001, 1)]
839    #[case(9.999_999, 9_999_999)]
840    fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
841        let result = millis_to_nanos(value).unwrap();
842        assert_eq!(result, expected);
843    }
844
845    #[rstest]
846    fn test_millis_to_nanos_unchecked_matches_checked() {
847        assert_eq!(
848            millis_to_nanos_unchecked(1.1),
849            millis_to_nanos(1.1).unwrap()
850        );
851    }
852
853    #[rstest]
854    #[case(0.0, 0)]
855    #[case(1.0, 1_000)]
856    #[case(1.1, 1_100)]
857    #[case(42.0, 42_000)]
858    #[case(0.1234, 123)]
859    #[case(0.01, 10)]
860    #[case(0.001, 1)]
861    #[case(9.999, 9_999)]
862    fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
863        let result = micros_to_nanos(value).unwrap();
864        assert_eq!(result, expected);
865    }
866
867    #[rstest]
868    fn test_micros_to_nanos_unchecked_matches_checked() {
869        assert_eq!(
870            micros_to_nanos_unchecked(1.1),
871            micros_to_nanos(1.1).unwrap()
872        );
873    }
874
875    #[rstest]
876    #[case(0, 0.0)]
877    #[case(1, 1e-09)]
878    #[case(1_000_000_000, 1.0)]
879    #[case(42_897_123_111, 42.897_123_111)]
880    fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
881        let result = nanos_to_secs(value);
882        assert_eq!(result, expected);
883    }
884
885    #[rstest]
886    #[case(0, 0)]
887    #[case(1_000_000, 1)]
888    #[case(1_000_000_000, 1000)]
889    #[case(42_897_123_111, 42897)]
890    fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
891        let result = nanos_to_millis(value);
892        assert_eq!(result, expected);
893    }
894
895    #[rstest]
896    #[case(0, 0)]
897    #[case(1_000, 1)]
898    #[case(1_000_000_000, 1_000_000)]
899    #[case(42_897_123, 42_897)]
900    fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
901        let result = nanos_to_micros(value);
902        assert_eq!(result, expected);
903    }
904
905    #[rstest]
906    #[case(0, "1970-01-01T00:00:00.000000000Z")] // Unix epoch
907    #[case(1, "1970-01-01T00:00:00.000000001Z")] // 1 nanosecond
908    #[case(1_000, "1970-01-01T00:00:00.000001000Z")] // 1 microsecond
909    #[case(1_000_000, "1970-01-01T00:00:00.001000000Z")] // 1 millisecond
910    #[case(1_000_000_000, "1970-01-01T00:00:01.000000000Z")] // 1 second
911    #[case(951_782_400_000_000_000, "2000-02-29T00:00:00.000000000Z")] // Leap day
912    #[case(1_609_459_199_999_999_999, "2020-12-31T23:59:59.999999999Z")] // Year boundary
913    #[case(1_702_857_600_000_000_000, "2023-12-18T00:00:00.000000000Z")] // Specific date
914    fn test_unix_nanos_to_iso8601(#[case] nanos: u64, #[case] expected: &str) {
915        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
916        assert_eq!(result, expected);
917    }
918
919    #[rstest]
920    #[case(0)]
921    #[case(1)]
922    #[case(951_782_400_123_456_789)]
923    #[case(1_609_459_199_999_999_999)]
924    #[case(i64::MAX as u64)]
925    #[case(5_097_600_000_000_000)] // 1970-03-01, civil leap-cycle boundary
926    #[case(5_356_800_000_000_000)] // 1970-03-04, civil leap-cycle boundary
927    fn test_unix_nanos_to_iso8601_matches_jiff_oracle(#[case] nanos: u64) {
928        let expected = format!(
929            "{:.9}",
930            Timestamp::from_nanosecond(i128::from(nanos)).unwrap()
931        );
932        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
933        assert_eq!(result, expected);
934    }
935
936    #[rstest]
937    #[case((i64::MAX as u64) + 1)]
938    #[case(u64::MAX)]
939    fn test_unix_nanos_to_iso8601_supports_full_unix_nanos_range(#[case] nanos: u64) {
940        let expected = format!(
941            "{:.9}",
942            Timestamp::from_nanosecond(i128::from(nanos)).unwrap()
943        );
944        let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
945        assert_eq!(result, expected);
946    }
947
948    #[rstest]
949    #[case(0, "1970-01-01T00:00:00.000Z")] // Unix epoch
950    #[case(1_000_000, "1970-01-01T00:00:00.001Z")] // 1 millisecond
951    #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] // 1 second
952    #[case(951_782_400_123_456_789, "2000-02-29T00:00:00.123Z")] // Leap day
953    #[case(1_609_459_199_999_999_999, "2020-12-31T23:59:59.999Z")] // Year boundary
954    #[case(1_702_857_600_123_456_789, "2023-12-18T00:00:00.123Z")] // With millisecond precision
955    fn test_unix_nanos_to_iso8601_millis(#[case] nanos: u64, #[case] expected: &str) {
956        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
957        assert_eq!(result, expected);
958    }
959
960    #[rstest]
961    #[case(0)]
962    #[case(951_782_400_123_456_789)]
963    #[case(1_609_459_199_999_999_999)]
964    #[case(i64::MAX as u64)]
965    fn test_unix_nanos_to_iso8601_millis_matches_jiff_oracle(#[case] nanos: u64) {
966        let expected = format!(
967            "{:.3}",
968            Timestamp::from_nanosecond(i128::from(nanos)).unwrap()
969        );
970        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
971        assert_eq!(result, expected);
972    }
973
974    #[rstest]
975    #[case((i64::MAX as u64) + 1)]
976    #[case(u64::MAX)]
977    fn test_unix_nanos_to_iso8601_millis_supports_full_unix_nanos_range(#[case] nanos: u64) {
978        let expected = format!(
979            "{:.3}",
980            Timestamp::from_nanosecond(i128::from(nanos)).unwrap()
981        );
982        let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
983        assert_eq!(result, expected);
984    }
985
986    // Sweep the full representable range against Jiff, complementing the fixed-point oracle
987    // cases above; any divergence in the integer date math surfaces as a mismatch here.
988    proptest! {
989        #[rstest]
990        fn prop_unix_nanos_to_iso8601_matches_jiff(nanos in any::<u64>()) {
991            let expected = format!(
992                "{:.9}",
993                Timestamp::from_nanosecond(i128::from(nanos)).unwrap(),
994            );
995            let actual = unix_nanos_to_iso8601(UnixNanos::from(nanos));
996            prop_assert_eq!(actual, expected);
997        }
998
999        #[rstest]
1000        fn prop_unix_nanos_to_iso8601_millis_matches_jiff(nanos in any::<u64>()) {
1001            let expected = format!(
1002                "{:.3}",
1003                Timestamp::from_nanosecond(i128::from(nanos)).unwrap(),
1004            );
1005            let actual = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
1006            prop_assert_eq!(actual, expected);
1007        }
1008    }
1009
1010    #[rstest]
1011    #[case(2023, 12, 15, 1_702_598_400_000_000_000)] // Fri
1012    #[case(2023, 12, 16, 1_702_598_400_000_000_000)] // Sat
1013    #[case(2023, 12, 17, 1_702_598_400_000_000_000)] // Sun
1014    #[case(2023, 12, 18, 1_702_857_600_000_000_000)] // Mon
1015    fn test_last_closest_weekday_nanos_with_valid_date(
1016        #[case] year: i32,
1017        #[case] month: u32,
1018        #[case] day: u32,
1019        #[case] expected: u64,
1020    ) {
1021        let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
1022        assert_eq!(result, expected);
1023    }
1024
1025    #[rstest]
1026    fn test_last_closest_weekday_nanos_with_invalid_date() {
1027        let result = last_weekday_nanos(2023, 4, 31);
1028        assert!(result.is_err());
1029    }
1030
1031    #[rstest]
1032    fn test_last_closest_weekday_nanos_with_nonexistent_date() {
1033        let result = last_weekday_nanos(2023, 2, 30);
1034        assert!(result.is_err());
1035    }
1036
1037    #[rstest]
1038    fn test_last_closest_weekday_nanos_with_invalid_conversion() {
1039        let result = last_weekday_nanos(9999, 12, 31);
1040        assert!(result.is_err());
1041    }
1042
1043    #[rstest]
1044    fn test_is_within_last_24_hours_when_now() {
1045        let now_ns = Timestamp::now().as_nanosecond();
1046        assert!(is_within_last_24_hours(UnixNanos::from(u64::try_from(now_ns).unwrap())).unwrap());
1047    }
1048
1049    #[rstest]
1050    fn test_is_within_last_24_hours_when_two_days_ago() {
1051        let past_ns = (Timestamp::now() - SignedDuration::from_hours(48)).as_nanosecond();
1052        assert!(
1053            !is_within_last_24_hours(UnixNanos::from(u64::try_from(past_ns).unwrap())).unwrap()
1054        );
1055    }
1056
1057    #[rstest]
1058    fn test_is_within_last_24_hours_when_future() {
1059        // Future timestamps should return false
1060        let future_ns = (Timestamp::now() + SignedDuration::from_hours(1)).as_nanosecond();
1061        assert!(
1062            !is_within_last_24_hours(UnixNanos::from(u64::try_from(future_ns).unwrap())).unwrap()
1063        );
1064
1065        // One day in the future should also return false
1066        let future_ns = (Timestamp::now() + SignedDuration::from_hours(24)).as_nanosecond();
1067        assert!(
1068            !is_within_last_24_hours(UnixNanos::from(u64::try_from(future_ns).unwrap())).unwrap()
1069        );
1070    }
1071
1072    #[rstest]
1073    #[case(
1074        timestamp("2024-03-31T12:00:00Z"),
1075        1,
1076        timestamp("2024-02-29T12:00:00Z")
1077    )]
1078    #[case(
1079        timestamp("2024-03-31T12:00:00Z"),
1080        12,
1081        timestamp("2023-03-31T12:00:00Z")
1082    )]
1083    #[case(
1084        timestamp("2024-01-31T12:00:00Z"),
1085        1,
1086        timestamp("2023-12-31T12:00:00Z")
1087    )]
1088    #[case(
1089        timestamp("2024-03-31T12:00:00Z"),
1090        2,
1091        timestamp("2024-01-31T12:00:00Z")
1092    )]
1093    fn test_subtract_n_months(
1094        #[case] input: Timestamp,
1095        #[case] months: u32,
1096        #[case] expected: Timestamp,
1097    ) {
1098        let result = subtract_n_months(input, months).unwrap();
1099        assert_eq!(result, expected);
1100    }
1101
1102    #[rstest]
1103    #[case(
1104        timestamp("2023-02-28T12:00:00Z"),
1105        1,
1106        timestamp("2023-03-28T12:00:00Z")
1107    )]
1108    #[case(
1109        timestamp("2024-01-31T12:00:00Z"),
1110        1,
1111        timestamp("2024-02-29T12:00:00Z")
1112    )]
1113    #[case(
1114        timestamp("2023-12-31T12:00:00Z"),
1115        1,
1116        timestamp("2024-01-31T12:00:00Z")
1117    )]
1118    #[case(
1119        timestamp("2023-01-31T12:00:00Z"),
1120        13,
1121        timestamp("2024-02-29T12:00:00Z")
1122    )]
1123    fn test_add_n_months(
1124        #[case] input: Timestamp,
1125        #[case] months: u32,
1126        #[case] expected: Timestamp,
1127    ) {
1128        let result = add_n_months(input, months).unwrap();
1129        assert_eq!(result, expected);
1130    }
1131
1132    #[rstest]
1133    fn test_add_n_years_overflow() {
1134        let datetime = timestamp("2024-01-01T00:00:00Z");
1135        let err = add_n_years(datetime, u32::MAX).unwrap_err();
1136        assert!(err.to_string().contains("month count overflow"));
1137    }
1138
1139    #[rstest]
1140    fn test_subtract_n_years_overflow() {
1141        let datetime = timestamp("2024-01-01T00:00:00Z");
1142        let err = subtract_n_years(datetime, u32::MAX).unwrap_err();
1143        assert!(err.to_string().contains("month count overflow"));
1144    }
1145
1146    #[rstest]
1147    fn test_add_n_years_nanos_overflow() {
1148        let nanos = UnixNanos::from(0);
1149        let err = add_n_years_nanos(nanos, u32::MAX).unwrap_err();
1150        assert!(err.to_string().contains("month count overflow"));
1151    }
1152
1153    #[rstest]
1154    #[case("1970-01-01T00:00:00.000000000Z", 0)] // Unix epoch
1155    #[case("1970-01-01T00:00:00.000000001Z", 1)] // 1 nanosecond
1156    #[case("1970-01-01T00:00:00.001000000Z", 1_000_000)] // 1 millisecond
1157    #[case("1970-01-01T00:00:01.000000000Z", 1_000_000_000)] // 1 second
1158    #[case("2023-12-18T00:00:00.000000000Z", 1_702_857_600_000_000_000)] // Specific date
1159    #[case("2024-02-10T14:58:43.456789Z", 1_707_577_123_456_789_000)] // RFC3339 with fractions
1160    #[case("2024-02-10T14:58:43Z", 1_707_577_123_000_000_000)] // RFC3339 without fractions
1161    #[case("2024-02-10", 1_707_523_200_000_000_000)] // Simple date format
1162    fn test_iso8601_to_unix_nanos(#[case] input: &str, #[case] expected: u64) {
1163        let result = iso8601_to_unix_nanos(input).unwrap();
1164        assert_eq!(result.as_u64(), expected);
1165    }
1166
1167    #[rstest]
1168    #[case("invalid-date")] // Invalid format
1169    #[case("2024-02-30")] // Invalid date
1170    #[case("2024-13-01")] // Invalid month
1171    #[case("not a timestamp")] // Random string
1172    fn test_iso8601_to_unix_nanos_invalid(#[case] input: &str) {
1173        let result = iso8601_to_unix_nanos(input);
1174        assert!(result.is_err());
1175    }
1176
1177    #[rstest]
1178    fn test_iso8601_roundtrip() {
1179        let original_nanos = UnixNanos::from(1_707_577_123_456_789_000);
1180        let iso8601_string = unix_nanos_to_iso8601(original_nanos);
1181        let parsed_nanos = iso8601_to_unix_nanos(&iso8601_string).unwrap();
1182        assert_eq!(parsed_nanos, original_nanos);
1183    }
1184
1185    #[rstest]
1186    fn test_add_n_years_nanos_normal_case() {
1187        // Test adding 1 year from 2020-01-01
1188        let start = UnixNanos::from(timestamp("2020-01-01T00:00:00Z"));
1189        let result = add_n_years_nanos(start, 1).unwrap();
1190        let expected = UnixNanos::from(timestamp("2021-01-01T00:00:00Z"));
1191        assert_eq!(result, expected);
1192    }
1193
1194    #[rstest]
1195    fn test_add_n_months_nanos_normal_case() {
1196        // Test adding 1 month from 2020-01-15
1197        let start = UnixNanos::from(timestamp("2020-01-15T00:00:00Z"));
1198        let result = add_n_months_nanos(start, 1).unwrap();
1199        let expected = UnixNanos::from(timestamp("2020-02-15T00:00:00Z"));
1200        assert_eq!(result, expected);
1201    }
1202
1203    #[rstest]
1204    fn test_add_n_years_nanos_from_epoch() {
1205        // Adding a year to the epoch can never go negative, this pins the exact value
1206        let start = UnixNanos::from(0);
1207        let result = add_n_years_nanos(start, 1).unwrap();
1208        assert_eq!(result.as_u64(), 31_536_000_000_000_000);
1209    }
1210
1211    #[rstest]
1212    fn test_datetime_to_unix_nanos_at_epoch() {
1213        // Unix epoch (1970-01-01 00:00:00 UTC) should return 0 nanoseconds
1214        let epoch = Timestamp::UNIX_EPOCH;
1215        let result = datetime_to_unix_nanos(Some(epoch));
1216        assert_eq!(result, Some(UnixNanos::from(0)));
1217    }
1218
1219    #[rstest]
1220    fn test_datetime_to_unix_nanos_typical_datetime() {
1221        let dt = timestamp("2024-01-15T13:30:45.123456789Z");
1222        let result = datetime_to_unix_nanos(Some(dt));
1223
1224        // Expected: 1705325445123456789 nanoseconds
1225        assert!(result.is_some());
1226        assert_eq!(result.unwrap().as_u64(), 1_705_325_445_123_456_789);
1227    }
1228
1229    #[rstest]
1230    fn test_datetime_to_unix_nanos_before_epoch() {
1231        // Pre-epoch datetime (1969-12-31 23:59:59 UTC) should return None
1232        // because negative timestamps can't be converted to u64
1233        let before_epoch = timestamp("1969-12-31T23:59:59Z");
1234        let result = datetime_to_unix_nanos(Some(before_epoch));
1235        assert_eq!(result, None);
1236    }
1237
1238    #[rstest]
1239    fn test_datetime_to_unix_nanos_one_second_after_epoch() {
1240        // 1970-01-01 00:00:01 UTC = 1_000_000_000 nanoseconds
1241        let dt = Timestamp::from_second(1).unwrap();
1242        let result = datetime_to_unix_nanos(Some(dt));
1243        assert_eq!(result, Some(UnixNanos::from(1_000_000_000)));
1244    }
1245
1246    #[rstest]
1247    fn test_datetime_to_unix_nanos_with_subsecond_precision() {
1248        // Test with microseconds: 1970-01-01 00:00:00.000001 UTC
1249        let dt = Timestamp::new(0, 1_000).unwrap(); // 1 microsecond = 1000 nanos
1250        let result = datetime_to_unix_nanos(Some(dt));
1251        assert_eq!(result, Some(UnixNanos::from(1_000)));
1252    }
1253
1254    #[rstest]
1255    fn test_try_datetime_to_unix_nanos_valid() {
1256        let dt = Timestamp::new(0, 1_000).unwrap();
1257        assert_eq!(
1258            try_datetime_to_unix_nanos(dt).unwrap(),
1259            UnixNanos::from(1_000)
1260        );
1261    }
1262
1263    #[rstest]
1264    fn test_try_datetime_to_unix_nanos_at_epoch() {
1265        assert_eq!(
1266            try_datetime_to_unix_nanos(Timestamp::UNIX_EPOCH).unwrap(),
1267            UnixNanos::from(0)
1268        );
1269    }
1270
1271    #[rstest]
1272    fn test_try_datetime_to_unix_nanos_before_epoch_errors() {
1273        let before_epoch = timestamp("1969-12-31T23:59:59Z");
1274        let err = try_datetime_to_unix_nanos(before_epoch).unwrap_err();
1275        assert!(
1276            err.to_string().contains("cannot be negative"),
1277            "unexpected error: {err}"
1278        );
1279    }
1280
1281    #[rstest]
1282    fn test_try_datetime_to_unix_nanos_out_of_range_errors() {
1283        let err = try_datetime_to_unix_nanos(Timestamp::MAX).unwrap_err();
1284        assert!(
1285            err.to_string().contains("out of range"),
1286            "unexpected error: {err}"
1287        );
1288    }
1289
1290    #[rstest]
1291    fn test_month_and_year_arithmetic_support_values_above_i64_max() {
1292        let large = UnixNanos::from(u64::MAX);
1293        assert!(subtract_n_months_nanos(large, 1).is_ok());
1294        assert!(add_n_months_nanos(large, 1).is_err());
1295        assert!(add_n_years_nanos(large, 1).is_err());
1296        assert!(subtract_n_years_nanos(large, 1).is_ok());
1297    }
1298
1299    #[rstest]
1300    fn test_subtract_n_months_nanos_pre_epoch_result_errors() {
1301        let epoch = UnixNanos::from(0);
1302        let err = subtract_n_months_nanos(epoch, 1).unwrap_err();
1303        assert_eq!(err.to_string(), "Negative timestamp not allowed");
1304    }
1305
1306    #[rstest]
1307    fn test_subtract_n_years_nanos_pre_epoch_result_errors() {
1308        let epoch = UnixNanos::from(0);
1309        let err = subtract_n_years_nanos(epoch, 1).unwrap_err();
1310        assert_eq!(err.to_string(), "Negative timestamp not allowed");
1311    }
1312
1313    #[rstest]
1314    fn test_subtract_n_months_nanos_at_epoch_boundary() {
1315        let epoch = UnixNanos::from(0);
1316        assert_eq!(subtract_n_months_nanos(epoch, 0).unwrap(), epoch);
1317    }
1318
1319    #[rstest]
1320    fn test_get_timezone_with_valid_name() {
1321        let tz = get_timezone("UTC").unwrap();
1322        assert_eq!(tz.iana_name(), Some("UTC"));
1323    }
1324
1325    #[rstest]
1326    fn test_get_timezone_with_unknown_name_errors() {
1327        assert!(get_timezone("Not/A_Zone").is_err());
1328    }
1329
1330    #[rstest]
1331    #[case(0, 0)]
1332    #[case(999, 0)]
1333    #[case(1_000, 1_000)]
1334    #[case(1_000_001, 1_000_000)]
1335    #[case(u64::MAX, 18_446_744_073_709_551_000)]
1336    fn test_floor_to_nearest_microsecond(#[case] input: u64, #[case] expected: u64) {
1337        assert_eq!(floor_to_nearest_microsecond(input), expected);
1338    }
1339}