use std::convert::TryFrom;
use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, TimeDelta, Utc, Weekday};
use crate::{UnixNanos, time::nanos_since_unix_epoch};
pub const MILLISECONDS_IN_SECOND: u64 = 1_000;
pub const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
pub const NANOSECONDS_IN_MILLISECOND: u64 = 1_000_000;
pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
pub const NANOSECONDS_IN_MINUTE: u64 = 60 * NANOSECONDS_IN_SECOND;
pub const NANOSECONDS_IN_DAY: u64 = 24 * 60 * NANOSECONDS_IN_MINUTE;
pub const SECONDS_IN_MINUTE: u64 = 60;
pub const SECONDS_IN_HOUR: u64 = 60 * SECONDS_IN_MINUTE;
pub const SECONDS_IN_DAY: u64 = 24 * SECONDS_IN_HOUR;
#[expect(
clippy::cast_precision_loss,
reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
)]
const MAX_SECS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_SECOND as f64;
#[expect(
clippy::cast_precision_loss,
reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
)]
const MAX_SECS_FOR_MILLIS: f64 = u64::MAX as f64 / MILLISECONDS_IN_SECOND as f64;
#[expect(
clippy::cast_precision_loss,
reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
)]
const MAX_MILLIS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MILLISECOND as f64;
#[expect(
clippy::cast_precision_loss,
reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
)]
const MAX_MICROS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MICROSECOND as f64;
const _: () = {
assert!(NANOSECONDS_IN_SECOND == 1_000_000_000);
assert!(NANOSECONDS_IN_MILLISECOND == 1_000_000);
assert!(NANOSECONDS_IN_MICROSECOND == 1_000);
assert!(MILLISECONDS_IN_SECOND == 1_000);
assert!(NANOSECONDS_IN_SECOND == MILLISECONDS_IN_SECOND * NANOSECONDS_IN_MILLISECOND);
assert!(NANOSECONDS_IN_MILLISECOND == NANOSECONDS_IN_MICROSECOND * 1_000);
assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MILLISECOND == 1_000);
assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MICROSECOND == 1_000_000);
assert!(SECONDS_IN_MINUTE == 60);
assert!(SECONDS_IN_HOUR == 3_600);
assert!(SECONDS_IN_DAY == 86_400);
assert!(NANOSECONDS_IN_MINUTE == 60 * NANOSECONDS_IN_SECOND);
assert!(NANOSECONDS_IN_DAY == 24 * 60 * NANOSECONDS_IN_MINUTE);
};
#[inline]
fn unix_nanos_to_datetime(unix_nanos: UnixNanos) -> anyhow::Result<DateTime<Utc>> {
let nanos_i64 = i64::try_from(unix_nanos.as_u64()).map_err(|_| {
anyhow::anyhow!(
"UnixNanos value {} exceeds maximum representable datetime (i64::MAX)",
unix_nanos.as_u64()
)
})?;
Ok(DateTime::from_timestamp_nanos(nanos_i64))
}
pub const WEEKDAYS: [Weekday; 5] = [
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
];
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "Intentional for unit conversion, may lose precision after clamping"
)]
pub fn secs_to_nanos(secs: f64) -> anyhow::Result<u64> {
anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
if secs <= 0.0 {
return Ok(0);
}
anyhow::ensure!(
secs <= MAX_SECS_FOR_NANOS,
"seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_NANOS}"
);
let nanos = secs * NANOSECONDS_IN_SECOND as f64;
Ok(nanos.trunc() as u64)
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "Intentional for unit conversion, may lose precision after clamping"
)]
pub fn secs_to_millis(secs: f64) -> anyhow::Result<u64> {
anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
if secs <= 0.0 {
return Ok(0);
}
anyhow::ensure!(
secs <= MAX_SECS_FOR_MILLIS,
"seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_MILLIS}"
);
let millis = secs * MILLISECONDS_IN_SECOND as f64;
Ok(millis.trunc() as u64)
}
#[must_use]
pub fn secs_to_nanos_unchecked(secs: f64) -> u64 {
secs_to_nanos(secs).expect("secs_to_nanos_unchecked: invalid or overflowing input")
}
#[must_use]
pub const fn mins_to_secs(mins: u64) -> u64 {
mins * SECONDS_IN_MINUTE
}
#[must_use]
pub const fn mins_to_nanos(mins: u64) -> u64 {
mins * NANOSECONDS_IN_MINUTE
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "Intentional for unit conversion, may lose precision after clamping"
)]
pub fn millis_to_nanos(millis: f64) -> anyhow::Result<u64> {
anyhow::ensure!(
millis.is_finite(),
"milliseconds must be finite, was {millis}"
);
if millis <= 0.0 {
return Ok(0);
}
anyhow::ensure!(
millis <= MAX_MILLIS_FOR_NANOS,
"milliseconds {millis} exceeds maximum representable value {MAX_MILLIS_FOR_NANOS}"
);
let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
Ok(nanos.trunc() as u64)
}
#[must_use]
pub fn millis_to_nanos_unchecked(millis: f64) -> u64 {
millis_to_nanos(millis).expect("millis_to_nanos_unchecked: invalid or overflowing input")
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "Intentional for unit conversion, may lose precision after clamping"
)]
pub fn micros_to_nanos(micros: f64) -> anyhow::Result<u64> {
anyhow::ensure!(
micros.is_finite(),
"microseconds must be finite, was {micros}"
);
if micros <= 0.0 {
return Ok(0);
}
anyhow::ensure!(
micros <= MAX_MICROS_FOR_NANOS,
"microseconds {micros} exceeds maximum representable value {MAX_MICROS_FOR_NANOS}"
);
let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
Ok(nanos.trunc() as u64)
}
#[must_use]
pub fn micros_to_nanos_unchecked(micros: f64) -> u64 {
micros_to_nanos(micros).expect("micros_to_nanos_unchecked: invalid or overflowing input")
}
#[expect(
clippy::cast_precision_loss,
reason = "Precision loss acceptable for time conversion"
)]
#[must_use]
pub fn nanos_to_secs(nanos: u64) -> f64 {
let seconds = nanos / NANOSECONDS_IN_SECOND;
let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
(seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
}
#[must_use]
pub const fn nanos_to_millis(nanos: u64) -> u64 {
nanos / NANOSECONDS_IN_MILLISECOND
}
#[must_use]
pub const fn nanos_to_micros(nanos: u64) -> u64 {
nanos / NANOSECONDS_IN_MICROSECOND
}
#[inline]
#[must_use]
pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
match unix_nanos_to_datetime(unix_nanos) {
Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Nanos, true),
Err(_) => unix_nanos.as_u64().to_string(),
}
}
#[inline]
pub fn iso8601_to_unix_nanos(date_string: &str) -> anyhow::Result<UnixNanos> {
date_string
.parse::<UnixNanos>()
.map_err(|e| anyhow::anyhow!("Failed to parse ISO 8601 string '{date_string}': {e}"))
}
#[inline]
#[must_use]
pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
match unix_nanos_to_datetime(unix_nanos) {
Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
Err(_) => unix_nanos.as_u64().to_string(),
}
}
#[must_use]
pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
(unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
}
pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
let date =
NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
let current_weekday = date.weekday().number_from_monday();
let offset = i64::from(match current_weekday {
1..=5 => 0, 6 => 1, _ => 2, });
let last_closest = date - TimeDelta::days(offset);
let unix_timestamp_ns = last_closest
.and_hms_nano_opt(0, 0, 0, 0)
.ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
let raw_ns = unix_timestamp_ns
.and_utc()
.timestamp_nanos_opt()
.ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))?;
let ns_u64 =
u64::try_from(raw_ns).map_err(|_| anyhow::anyhow!("Negative timestamp: {raw_ns}"))?;
Ok(UnixNanos::from(ns_u64))
}
pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
let timestamp_ns = timestamp_ns.as_u64();
let now_ns = nanos_since_unix_epoch();
if timestamp_ns > now_ns {
return Ok(false);
}
Ok(now_ns - timestamp_ns <= NANOSECONDS_IN_DAY)
}
pub fn subtract_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
match datetime.checked_sub_months(chrono::Months::new(n)) {
Some(result) => Ok(result),
None => anyhow::bail!("Failed to subtract {n} months from {datetime}"),
}
}
pub fn add_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
match datetime.checked_add_months(chrono::Months::new(n)) {
Some(result) => Ok(result),
None => anyhow::bail!("Failed to add {n} months to {datetime}"),
}
}
#[expect(
clippy::cast_sign_loss,
reason = "explicit `if timestamp < 0` guard before the cast"
)]
pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
let datetime = unix_nanos_to_datetime(unix_nanos)?;
let result = subtract_n_months(datetime, n)?;
let timestamp = match result.timestamp_nanos_opt() {
Some(ts) => ts,
None => anyhow::bail!("Timestamp out of range after subtracting {n} months"),
};
if timestamp < 0 {
anyhow::bail!("Negative timestamp not allowed");
}
Ok(UnixNanos::from(timestamp as u64))
}
#[expect(
clippy::cast_sign_loss,
reason = "explicit `if timestamp < 0` guard before the cast"
)]
pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
let datetime = unix_nanos_to_datetime(unix_nanos)?;
let result = add_n_months(datetime, n)?;
let timestamp = match result.timestamp_nanos_opt() {
Some(ts) => ts,
None => anyhow::bail!("Timestamp out of range after adding {n} months"),
};
if timestamp < 0 {
anyhow::bail!("Negative timestamp not allowed");
}
Ok(UnixNanos::from(timestamp as u64))
}
pub fn add_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
let months = n.checked_mul(12).ok_or_else(|| {
anyhow::anyhow!("Failed to add {n} years to {datetime}: month count overflow")
})?;
match datetime.checked_add_months(chrono::Months::new(months)) {
Some(result) => Ok(result),
None => anyhow::bail!("Failed to add {n} years to {datetime}"),
}
}
pub fn subtract_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
let months = n.checked_mul(12).ok_or_else(|| {
anyhow::anyhow!("Failed to subtract {n} years from {datetime}: month count overflow")
})?;
match datetime.checked_sub_months(chrono::Months::new(months)) {
Some(result) => Ok(result),
None => anyhow::bail!("Failed to subtract {n} years from {datetime}"),
}
}
#[expect(
clippy::cast_sign_loss,
reason = "explicit `if timestamp < 0` guard before the cast"
)]
pub fn add_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
let datetime = unix_nanos_to_datetime(unix_nanos)?;
let result = add_n_years(datetime, n)?;
let timestamp = match result.timestamp_nanos_opt() {
Some(ts) => ts,
None => anyhow::bail!("Timestamp out of range after adding {n} years"),
};
if timestamp < 0 {
anyhow::bail!("Negative timestamp not allowed");
}
Ok(UnixNanos::from(timestamp as u64))
}
#[expect(
clippy::cast_sign_loss,
reason = "explicit `if timestamp < 0` guard before the cast"
)]
pub fn subtract_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
let datetime = unix_nanos_to_datetime(unix_nanos)?;
let result = subtract_n_years(datetime, n)?;
let timestamp = match result.timestamp_nanos_opt() {
Some(ts) => ts,
None => anyhow::bail!("Timestamp out of range after subtracting {n} years"),
};
if timestamp < 0 {
anyhow::bail!("Negative timestamp not allowed");
}
Ok(UnixNanos::from(timestamp as u64))
}
#[must_use]
pub const fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
if month < 1 || month > 12 {
return None;
}
Some(match month {
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31, })
}
#[must_use]
pub const fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn datetime_to_unix_nanos(value: Option<DateTime<Utc>>) -> Option<UnixNanos> {
value
.and_then(|dt| dt.timestamp_nanos_opt())
.and_then(|nanos| u64::try_from(nanos).ok())
.map(UnixNanos::from)
}
#[cfg(test)]
#[expect(
clippy::float_cmp,
reason = "Exact float comparisons acceptable in tests"
)]
mod tests {
use chrono::{DateTime, TimeDelta, TimeZone, Timelike, Utc};
use rstest::rstest;
use super::*;
#[rstest]
#[case(0.0, 0)]
#[case(1.0, 1_000_000_000)]
#[case(1.1, 1_100_000_000)]
#[case(42.0, 42_000_000_000)]
#[case(0.000_123_5, 123_500)]
#[case(0.000_000_01, 10)]
#[case(0.000_000_001, 1)]
#[case(9.999_999_999, 9_999_999_999)]
fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
let result = secs_to_nanos(value).unwrap();
assert_eq!(result, expected);
}
#[rstest]
#[case(0.0, 0)]
#[case(1.0, 1_000)]
#[case(1.1, 1_100)]
#[case(42.0, 42_000)]
#[case(0.012_34, 12)]
#[case(0.001, 1)]
fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
let result = secs_to_millis(value).unwrap();
assert_eq!(result, expected);
}
#[rstest]
fn test_secs_to_nanos_unchecked_matches_checked() {
assert_eq!(secs_to_nanos_unchecked(1.1), secs_to_nanos(1.1).unwrap());
}
#[rstest]
fn test_secs_to_nanos_non_finite_errors() {
let err = secs_to_nanos(f64::NAN).unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[rstest]
fn test_secs_to_nanos_overflow_errors() {
let err = secs_to_nanos(MAX_SECS_FOR_NANOS + 1.0).unwrap_err();
assert!(err.to_string().contains("exceeds"));
}
#[rstest]
fn test_secs_to_millis_non_finite_errors() {
let err = secs_to_millis(f64::INFINITY).unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[rstest]
fn test_millis_to_nanos_overflow_errors() {
let err = millis_to_nanos(MAX_MILLIS_FOR_NANOS + 1.0).unwrap_err();
assert!(err.to_string().contains("exceeds"));
}
#[rstest]
fn test_millis_to_nanos_non_finite_errors() {
let err = millis_to_nanos(f64::NEG_INFINITY).unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[rstest]
fn test_micros_to_nanos_non_finite_errors() {
let err = micros_to_nanos(f64::NAN).unwrap_err();
assert!(err.to_string().contains("finite"));
}
#[rstest]
#[case(0, 0)]
#[case(1, 60)]
#[case(5, 300)]
#[case(60, 3600)]
#[case(1440, 86400)]
fn test_mins_to_secs(#[case] mins: u64, #[case] expected: u64) {
assert_eq!(mins_to_secs(mins), expected);
}
#[rstest]
#[case(0, 0)]
#[case(1, 60_000_000_000)]
#[case(5, 300_000_000_000)]
#[case(60, 3_600_000_000_000)]
fn test_mins_to_nanos(#[case] mins: u64, #[case] expected: u64) {
assert_eq!(mins_to_nanos(mins), expected);
}
#[rstest]
fn test_micros_to_nanos_overflow_errors() {
let err = micros_to_nanos(MAX_MICROS_FOR_NANOS * 2.0).unwrap_err();
assert!(err.to_string().contains("exceeds"));
}
#[rstest]
fn test_secs_to_nanos_negative_infinity_errors() {
let result = secs_to_nanos(f64::NEG_INFINITY);
assert!(result.is_err());
}
#[rstest]
#[case(2024, 0)] #[case(2024, 13)] fn test_last_day_of_month_invalid_month(#[case] year: i32, #[case] month: u32) {
assert!(last_day_of_month(year, month).is_none());
}
#[rstest]
#[case(0.0, 0)]
#[case(1.0, 1_000_000)]
#[case(1.1, 1_100_000)]
#[case(42.0, 42_000_000)]
#[case(0.000_123_4, 123)]
#[case(0.000_01, 10)]
#[case(0.000_001, 1)]
#[case(9.999_999, 9_999_999)]
fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
let result = millis_to_nanos(value).unwrap();
assert_eq!(result, expected);
}
#[rstest]
fn test_millis_to_nanos_unchecked_matches_checked() {
assert_eq!(
millis_to_nanos_unchecked(1.1),
millis_to_nanos(1.1).unwrap()
);
}
#[rstest]
#[case(0.0, 0)]
#[case(1.0, 1_000)]
#[case(1.1, 1_100)]
#[case(42.0, 42_000)]
#[case(0.1234, 123)]
#[case(0.01, 10)]
#[case(0.001, 1)]
#[case(9.999, 9_999)]
fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
let result = micros_to_nanos(value).unwrap();
assert_eq!(result, expected);
}
#[rstest]
fn test_micros_to_nanos_unchecked_matches_checked() {
assert_eq!(
micros_to_nanos_unchecked(1.1),
micros_to_nanos(1.1).unwrap()
);
}
#[rstest]
#[case(0, 0.0)]
#[case(1, 1e-09)]
#[case(1_000_000_000, 1.0)]
#[case(42_897_123_111, 42.897_123_111)]
fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
let result = nanos_to_secs(value);
assert_eq!(result, expected);
}
#[rstest]
#[case(0, 0)]
#[case(1_000_000, 1)]
#[case(1_000_000_000, 1000)]
#[case(42_897_123_111, 42897)]
fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
let result = nanos_to_millis(value);
assert_eq!(result, expected);
}
#[rstest]
#[case(0, 0)]
#[case(1_000, 1)]
#[case(1_000_000_000, 1_000_000)]
#[case(42_897_123, 42_897)]
fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
let result = nanos_to_micros(value);
assert_eq!(result, expected);
}
#[rstest]
#[case(0, "1970-01-01T00:00:00.000000000Z")] #[case(1, "1970-01-01T00:00:00.000000001Z")] #[case(1_000, "1970-01-01T00:00:00.000001000Z")] #[case(1_000_000, "1970-01-01T00:00:00.001000000Z")] #[case(1_000_000_000, "1970-01-01T00:00:01.000000000Z")] #[case(1_702_857_600_000_000_000, "2023-12-18T00:00:00.000000000Z")] fn test_unix_nanos_to_iso8601(#[case] nanos: u64, #[case] expected: &str) {
let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
assert_eq!(result, expected);
}
#[rstest]
#[case(0, "1970-01-01T00:00:00.000Z")] #[case(1_000_000, "1970-01-01T00:00:00.001Z")] #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] #[case(1_702_857_600_123_456_789, "2023-12-18T00:00:00.123Z")] fn test_unix_nanos_to_iso8601_millis(#[case] nanos: u64, #[case] expected: &str) {
let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
assert_eq!(result, expected);
}
#[rstest]
#[case(2023, 12, 15, 1_702_598_400_000_000_000)] #[case(2023, 12, 16, 1_702_598_400_000_000_000)] #[case(2023, 12, 17, 1_702_598_400_000_000_000)] #[case(2023, 12, 18, 1_702_857_600_000_000_000)] fn test_last_closest_weekday_nanos_with_valid_date(
#[case] year: i32,
#[case] month: u32,
#[case] day: u32,
#[case] expected: u64,
) {
let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
assert_eq!(result, expected);
}
#[rstest]
fn test_last_closest_weekday_nanos_with_invalid_date() {
let result = last_weekday_nanos(2023, 4, 31);
assert!(result.is_err());
}
#[rstest]
fn test_last_closest_weekday_nanos_with_nonexistent_date() {
let result = last_weekday_nanos(2023, 2, 30);
assert!(result.is_err());
}
#[rstest]
fn test_last_closest_weekday_nanos_with_invalid_conversion() {
let result = last_weekday_nanos(9999, 12, 31);
assert!(result.is_err());
}
#[rstest]
fn test_is_within_last_24_hours_when_now() {
let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
assert!(is_within_last_24_hours(UnixNanos::from(now_ns.cast_unsigned())).unwrap());
}
#[rstest]
fn test_is_within_last_24_hours_when_two_days_ago() {
let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
.timestamp_nanos_opt()
.unwrap();
assert!(!is_within_last_24_hours(UnixNanos::from(past_ns.cast_unsigned())).unwrap());
}
#[rstest]
fn test_is_within_last_24_hours_when_future() {
let future_ns = (Utc::now() + TimeDelta::try_hours(1).unwrap())
.timestamp_nanos_opt()
.unwrap();
assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
let future_ns = (Utc::now() + TimeDelta::try_days(1).unwrap())
.timestamp_nanos_opt()
.unwrap();
assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
}
#[rstest]
#[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 12, Utc.with_ymd_and_hms(2023, 3, 31, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 2, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] fn test_subtract_n_months(
#[case] input: DateTime<Utc>,
#[case] months: u32,
#[case] expected: DateTime<Utc>,
) {
let result = subtract_n_months(input, months).unwrap();
assert_eq!(result, expected);
}
#[rstest]
#[case(Utc.with_ymd_and_hms(2023, 2, 28, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 3, 28, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2023, 12, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 1, 31, 12, 0, 0).unwrap())] #[case(Utc.with_ymd_and_hms(2023, 1, 31, 12, 0, 0).unwrap(), 13, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] fn test_add_n_months(
#[case] input: DateTime<Utc>,
#[case] months: u32,
#[case] expected: DateTime<Utc>,
) {
let result = add_n_months(input, months).unwrap();
assert_eq!(result, expected);
}
#[rstest]
fn test_add_n_years_overflow() {
let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let err = add_n_years(datetime, u32::MAX).unwrap_err();
assert!(err.to_string().contains("month count overflow"));
}
#[rstest]
fn test_subtract_n_years_overflow() {
let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let err = subtract_n_years(datetime, u32::MAX).unwrap_err();
assert!(err.to_string().contains("month count overflow"));
}
#[rstest]
fn test_add_n_years_nanos_overflow() {
let nanos = UnixNanos::from(0);
let err = add_n_years_nanos(nanos, u32::MAX).unwrap_err();
assert!(err.to_string().contains("month count overflow"));
}
#[rstest]
#[case(2024, 2, 29)] #[case(2023, 2, 28)] #[case(2024, 12, 31)] #[case(2023, 11, 30)] fn test_last_day_of_month(#[case] year: i32, #[case] month: u32, #[case] expected: u32) {
let result = last_day_of_month(year, month).unwrap();
assert_eq!(result, expected);
}
#[rstest]
#[case(2024, true)] #[case(1900, false)] #[case(2000, true)] #[case(2023, false)] fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
let result = is_leap_year(year);
assert_eq!(result, expected);
}
#[rstest]
#[case("1970-01-01T00:00:00.000000000Z", 0)] #[case("1970-01-01T00:00:00.000000001Z", 1)] #[case("1970-01-01T00:00:00.001000000Z", 1_000_000)] #[case("1970-01-01T00:00:01.000000000Z", 1_000_000_000)] #[case("2023-12-18T00:00:00.000000000Z", 1_702_857_600_000_000_000)] #[case("2024-02-10T14:58:43.456789Z", 1_707_577_123_456_789_000)] #[case("2024-02-10T14:58:43Z", 1_707_577_123_000_000_000)] #[case("2024-02-10", 1_707_523_200_000_000_000)] fn test_iso8601_to_unix_nanos(#[case] input: &str, #[case] expected: u64) {
let result = iso8601_to_unix_nanos(input).unwrap();
assert_eq!(result.as_u64(), expected);
}
#[rstest]
#[case("invalid-date")] #[case("2024-02-30")] #[case("2024-13-01")] #[case("not a timestamp")] fn test_iso8601_to_unix_nanos_invalid(#[case] input: &str) {
let result = iso8601_to_unix_nanos(input);
assert!(result.is_err());
}
#[rstest]
fn test_iso8601_roundtrip() {
let original_nanos = UnixNanos::from(1_707_577_123_456_789_000);
let iso8601_string = unix_nanos_to_iso8601(original_nanos);
let parsed_nanos = iso8601_to_unix_nanos(&iso8601_string).unwrap();
assert_eq!(parsed_nanos, original_nanos);
}
#[rstest]
fn test_add_n_years_nanos_normal_case() {
let start = UnixNanos::from(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap());
let result = add_n_years_nanos(start, 1).unwrap();
let expected = UnixNanos::from(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap());
assert_eq!(result, expected);
}
#[rstest]
fn test_add_n_years_nanos_prevents_negative_timestamp() {
let start = UnixNanos::from(0); let result = add_n_years_nanos(start, 1);
assert!(result.is_ok());
}
#[rstest]
fn test_datetime_to_unix_nanos_at_epoch() {
let epoch = Utc.timestamp_opt(0, 0).unwrap();
let result = datetime_to_unix_nanos(Some(epoch));
assert_eq!(result, Some(UnixNanos::from(0)));
}
#[rstest]
fn test_datetime_to_unix_nanos_typical_datetime() {
let dt = Utc
.with_ymd_and_hms(2024, 1, 15, 13, 30, 45)
.unwrap()
.with_nanosecond(123_456_789)
.unwrap();
let result = datetime_to_unix_nanos(Some(dt));
assert!(result.is_some());
assert_eq!(result.unwrap().as_u64(), 1_705_325_445_123_456_789);
}
#[rstest]
fn test_datetime_to_unix_nanos_before_epoch() {
let before_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 59).unwrap();
let result = datetime_to_unix_nanos(Some(before_epoch));
assert_eq!(result, None);
}
#[rstest]
fn test_datetime_to_unix_nanos_one_second_after_epoch() {
let dt = Utc.timestamp_opt(1, 0).unwrap();
let result = datetime_to_unix_nanos(Some(dt));
assert_eq!(result, Some(UnixNanos::from(1_000_000_000)));
}
#[rstest]
fn test_datetime_to_unix_nanos_with_subsecond_precision() {
let dt = Utc.timestamp_opt(0, 1_000).unwrap(); let result = datetime_to_unix_nanos(Some(dt));
assert_eq!(result, Some(UnixNanos::from(1_000)));
}
#[rstest]
fn test_nanos_helpers_return_err_for_values_above_i64_max() {
let large = UnixNanos::from(u64::MAX);
assert!(subtract_n_months_nanos(large, 1).is_err());
assert!(add_n_months_nanos(large, 1).is_err());
assert!(add_n_years_nanos(large, 1).is_err());
assert!(subtract_n_years_nanos(large, 1).is_err());
}
}