1use std::convert::TryFrom;
18
19use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc, Weekday};
20
21use crate::{UnixNanos, time::nanos_since_unix_epoch};
22
23pub const MILLISECONDS_IN_SECOND: u64 = 1_000;
25
26pub const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
28
29pub const NANOSECONDS_IN_MILLISECOND: u64 = 1_000_000;
31const NANOSECONDS_IN_MILLISECOND_U32: u32 = 1_000_000;
32
33pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
35
36pub const NANOSECONDS_IN_MINUTE: u64 = 60 * NANOSECONDS_IN_SECOND;
38
39pub const NANOSECONDS_IN_DAY: u64 = 24 * 60 * NANOSECONDS_IN_MINUTE;
41
42pub const SECONDS_IN_MINUTE: u64 = 60;
44
45pub const SECONDS_IN_HOUR: u64 = 60 * SECONDS_IN_MINUTE;
47
48pub const SECONDS_IN_DAY: u64 = 24 * SECONDS_IN_HOUR;
50
51#[expect(
53 clippy::cast_precision_loss,
54 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
55)]
56const MAX_SECS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_SECOND as f64;
57#[expect(
59 clippy::cast_precision_loss,
60 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
61)]
62const MAX_SECS_FOR_MILLIS: f64 = u64::MAX as f64 / MILLISECONDS_IN_SECOND as f64;
63#[expect(
65 clippy::cast_precision_loss,
66 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
67)]
68const MAX_MILLIS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MILLISECOND as f64;
69#[expect(
71 clippy::cast_precision_loss,
72 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
73)]
74const MAX_MICROS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MICROSECOND as f64;
75
76const _: () = {
78 assert!(NANOSECONDS_IN_SECOND == 1_000_000_000);
79 assert!(NANOSECONDS_IN_MILLISECOND == 1_000_000);
80 assert!(NANOSECONDS_IN_MICROSECOND == 1_000);
81 assert!(MILLISECONDS_IN_SECOND == 1_000);
82 assert!(NANOSECONDS_IN_SECOND == MILLISECONDS_IN_SECOND * NANOSECONDS_IN_MILLISECOND);
83 assert!(NANOSECONDS_IN_MILLISECOND == NANOSECONDS_IN_MICROSECOND * 1_000);
84 assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MILLISECOND == 1_000);
85 assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MICROSECOND == 1_000_000);
86 assert!(SECONDS_IN_MINUTE == 60);
87 assert!(SECONDS_IN_HOUR == 3_600);
88 assert!(SECONDS_IN_DAY == 86_400);
89 assert!(NANOSECONDS_IN_MINUTE == 60 * NANOSECONDS_IN_SECOND);
90 assert!(NANOSECONDS_IN_DAY == 24 * 60 * NANOSECONDS_IN_MINUTE);
91};
92
93#[inline]
94fn unix_nanos_to_datetime(unix_nanos: UnixNanos) -> anyhow::Result<DateTime<Utc>> {
95 let nanos_i64 = i64::try_from(unix_nanos.as_u64()).map_err(|_| {
96 anyhow::anyhow!(
97 "UnixNanos value {} exceeds maximum representable datetime (i64::MAX)",
98 unix_nanos.as_u64()
99 )
100 })?;
101 Ok(DateTime::from_timestamp_nanos(nanos_i64))
102}
103
104fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
105 let z = days_since_epoch + 719_468;
109 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
110 let day_of_era = z - era * 146_097;
111 let year_of_era =
112 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
113 let year = year_of_era + era * 400;
114 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
115 let month_prime = (5 * day_of_year + 2) / 153;
116 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
117 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
118 let year = year + i64::from(month <= 2);
119
120 (
121 i32::try_from(year).expect("year fits in i32"),
122 u32::try_from(month).expect("month is positive"),
123 u32::try_from(day).expect("day is positive"),
124 )
125}
126
127struct DateTimeParts {
128 year: i32,
129 month: u32,
130 day: u32,
131 hour: u32,
132 minute: u32,
133 second: u32,
134 subsec_nanos: u32,
135}
136
137#[expect(
138 clippy::cast_possible_truncation,
139 reason = "digit helpers only receive values in 0..=9"
140)]
141fn push_digit(out: &mut String, digit: u32) {
142 out.push(char::from(b'0' + digit as u8));
143}
144
145fn push_2_digits(out: &mut String, value: u32) {
146 debug_assert!(value < 100);
147 push_digit(out, value / 10);
148 push_digit(out, value % 10);
149}
150
151fn push_3_digits(out: &mut String, value: u32) {
152 debug_assert!(value < 1_000);
153 push_digit(out, value / 100);
154 push_2_digits(out, value % 100);
155}
156
157fn push_4_digits(out: &mut String, value: i32) {
158 debug_assert!((0..=9_999).contains(&value));
159 let value = u32::try_from(value).expect("year is non-negative");
160 push_digit(out, value / 1_000);
161 push_digit(out, (value / 100) % 10);
162 push_2_digits(out, value % 100);
163}
164
165fn push_9_digits(out: &mut String, value: u32) {
166 debug_assert!(value < 1_000_000_000);
167 let mut divisor = 100_000_000;
168 while divisor > 0 {
169 push_digit(out, value / divisor % 10);
170 divisor /= 10;
171 }
172}
173
174fn split_unix_nanos(unix_nanos: UnixNanos) -> Option<DateTimeParts> {
175 let nanos = unix_nanos.as_u64();
176 if i64::try_from(nanos).is_err() {
177 return None;
178 }
179
180 let total_seconds = nanos / NANOSECONDS_IN_SECOND;
181 let subsec_nanos = u32::try_from(nanos % NANOSECONDS_IN_SECOND).expect("subsecond fits u32");
182 let days = total_seconds / SECONDS_IN_DAY;
183 let seconds_of_day = total_seconds % SECONDS_IN_DAY;
184 let (year, month, day) =
185 civil_from_days(i64::try_from(days).expect("days since epoch fits i64"));
186 let hour = u32::try_from(seconds_of_day / SECONDS_IN_HOUR).expect("hour fits u32");
187 let minute =
188 u32::try_from((seconds_of_day % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE).expect("minute fits");
189 let second = u32::try_from(seconds_of_day % SECONDS_IN_MINUTE).expect("second fits");
190
191 Some(DateTimeParts {
192 year,
193 month,
194 day,
195 hour,
196 minute,
197 second,
198 subsec_nanos,
199 })
200}
201
202fn push_iso8601_prefix(
203 out: &mut String,
204 year: i32,
205 month: u32,
206 day: u32,
207 hour: u32,
208 minute: u32,
209 second: u32,
210) {
211 push_4_digits(out, year);
212 out.push('-');
213 push_2_digits(out, month);
214 out.push('-');
215 push_2_digits(out, day);
216 out.push('T');
217 push_2_digits(out, hour);
218 out.push(':');
219 push_2_digits(out, minute);
220 out.push(':');
221 push_2_digits(out, second);
222 out.push('.');
223}
224
225pub const WEEKDAYS: [Weekday; 5] = [
227 Weekday::Mon,
228 Weekday::Tue,
229 Weekday::Wed,
230 Weekday::Thu,
231 Weekday::Fri,
232];
233
234#[expect(
240 clippy::cast_possible_truncation,
241 clippy::cast_sign_loss,
242 clippy::cast_precision_loss,
243 reason = "Intentional for unit conversion, may lose precision after clamping"
244)]
245pub fn secs_to_nanos(secs: f64) -> anyhow::Result<u64> {
246 anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
247 if secs <= 0.0 {
248 return Ok(0);
249 }
250 anyhow::ensure!(
251 secs <= MAX_SECS_FOR_NANOS,
252 "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_NANOS}"
253 );
254 let nanos = secs * NANOSECONDS_IN_SECOND as f64;
255 Ok(nanos.trunc() as u64)
256}
257
258#[expect(
264 clippy::cast_possible_truncation,
265 clippy::cast_sign_loss,
266 clippy::cast_precision_loss,
267 reason = "Intentional for unit conversion, may lose precision after clamping"
268)]
269pub fn secs_to_millis(secs: f64) -> anyhow::Result<u64> {
270 anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
271 if secs <= 0.0 {
272 return Ok(0);
273 }
274 anyhow::ensure!(
275 secs <= MAX_SECS_FOR_MILLIS,
276 "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_MILLIS}"
277 );
278 let millis = secs * MILLISECONDS_IN_SECOND as f64;
279 Ok(millis.trunc() as u64)
280}
281
282#[must_use]
291pub fn secs_to_nanos_unchecked(secs: f64) -> u64 {
292 secs_to_nanos(secs).expect("secs_to_nanos_unchecked: invalid or overflowing input")
293}
294
295#[must_use]
297pub const fn mins_to_secs(mins: u64) -> u64 {
298 mins * SECONDS_IN_MINUTE
299}
300
301#[must_use]
303pub const fn mins_to_nanos(mins: u64) -> u64 {
304 mins * NANOSECONDS_IN_MINUTE
305}
306
307#[expect(
316 clippy::cast_possible_truncation,
317 clippy::cast_sign_loss,
318 clippy::cast_precision_loss,
319 reason = "Intentional for unit conversion, may lose precision after clamping"
320)]
321pub fn millis_to_nanos(millis: f64) -> anyhow::Result<u64> {
322 anyhow::ensure!(
323 millis.is_finite(),
324 "milliseconds must be finite, was {millis}"
325 );
326
327 if millis <= 0.0 {
328 return Ok(0);
329 }
330 anyhow::ensure!(
331 millis <= MAX_MILLIS_FOR_NANOS,
332 "milliseconds {millis} exceeds maximum representable value {MAX_MILLIS_FOR_NANOS}"
333 );
334 let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
335 Ok(nanos.trunc() as u64)
336}
337
338#[must_use]
344pub fn millis_to_nanos_unchecked(millis: f64) -> u64 {
345 millis_to_nanos(millis).expect("millis_to_nanos_unchecked: invalid or overflowing input")
346}
347
348#[expect(
357 clippy::cast_possible_truncation,
358 clippy::cast_sign_loss,
359 clippy::cast_precision_loss,
360 reason = "Intentional for unit conversion, may lose precision after clamping"
361)]
362pub fn micros_to_nanos(micros: f64) -> anyhow::Result<u64> {
363 anyhow::ensure!(
364 micros.is_finite(),
365 "microseconds must be finite, was {micros}"
366 );
367
368 if micros <= 0.0 {
369 return Ok(0);
370 }
371 anyhow::ensure!(
372 micros <= MAX_MICROS_FOR_NANOS,
373 "microseconds {micros} exceeds maximum representable value {MAX_MICROS_FOR_NANOS}"
374 );
375 let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
376 Ok(nanos.trunc() as u64)
377}
378
379#[must_use]
385pub fn micros_to_nanos_unchecked(micros: f64) -> u64 {
386 micros_to_nanos(micros).expect("micros_to_nanos_unchecked: invalid or overflowing input")
387}
388
389#[expect(
394 clippy::cast_precision_loss,
395 reason = "Precision loss acceptable for time conversion"
396)]
397#[must_use]
398pub fn nanos_to_secs(nanos: u64) -> f64 {
399 let seconds = nanos / NANOSECONDS_IN_SECOND;
400 let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
401 (seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
402}
403
404#[must_use]
406pub const fn nanos_to_millis(nanos: u64) -> u64 {
407 nanos / NANOSECONDS_IN_MILLISECOND
408}
409
410#[must_use]
412pub const fn nanos_to_micros(nanos: u64) -> u64 {
413 nanos / NANOSECONDS_IN_MICROSECOND
414}
415
416#[inline]
421#[must_use]
422pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
423 let Some(parts) = split_unix_nanos(unix_nanos) else {
424 return unix_nanos.as_u64().to_string();
425 };
426
427 let mut out = String::with_capacity(30);
428 push_iso8601_prefix(
429 &mut out,
430 parts.year,
431 parts.month,
432 parts.day,
433 parts.hour,
434 parts.minute,
435 parts.second,
436 );
437 push_9_digits(&mut out, parts.subsec_nanos);
438 out.push('Z');
439 out
440}
441
442#[inline]
465pub fn iso8601_to_unix_nanos(date_string: &str) -> anyhow::Result<UnixNanos> {
466 date_string
467 .parse::<UnixNanos>()
468 .map_err(|e| anyhow::anyhow!("Failed to parse ISO 8601 string '{date_string}': {e}"))
469}
470
471#[inline]
477#[must_use]
478pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
479 let Some(parts) = split_unix_nanos(unix_nanos) else {
480 return unix_nanos.as_u64().to_string();
481 };
482
483 let mut out = String::with_capacity(24);
484 push_iso8601_prefix(
485 &mut out,
486 parts.year,
487 parts.month,
488 parts.day,
489 parts.hour,
490 parts.minute,
491 parts.second,
492 );
493 push_3_digits(
494 &mut out,
495 parts.subsec_nanos / NANOSECONDS_IN_MILLISECOND_U32,
496 );
497 out.push('Z');
498 out
499}
500
501#[must_use]
503pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
504 (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
505}
506
507pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
513 let date =
514 NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
515 let current_weekday = date.weekday().number_from_monday();
516
517 let offset = i64::from(match current_weekday {
519 1..=5 => 0, 6 => 1, _ => 2, });
523 let last_closest = date - TimeDelta::days(offset);
525
526 let unix_timestamp_ns = last_closest
528 .and_hms_nano_opt(0, 0, 0, 0)
529 .ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
530
531 let raw_ns = unix_timestamp_ns
533 .and_utc()
534 .timestamp_nanos_opt()
535 .ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))?;
536 let ns_u64 =
537 u64::try_from(raw_ns).map_err(|_| anyhow::anyhow!("Negative timestamp: {raw_ns}"))?;
538 Ok(UnixNanos::from(ns_u64))
539}
540
541pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
547 let timestamp_ns = timestamp_ns.as_u64();
551 let now_ns = nanos_since_unix_epoch();
552
553 if timestamp_ns > now_ns {
555 return Ok(false);
556 }
557
558 Ok(now_ns - timestamp_ns <= NANOSECONDS_IN_DAY)
559}
560
561pub fn subtract_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
567 match datetime.checked_sub_months(chrono::Months::new(n)) {
568 Some(result) => Ok(result),
569 None => anyhow::bail!("Failed to subtract {n} months from {datetime}"),
570 }
571}
572
573pub fn add_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
579 match datetime.checked_add_months(chrono::Months::new(n)) {
580 Some(result) => Ok(result),
581 None => anyhow::bail!("Failed to add {n} months to {datetime}"),
582 }
583}
584
585pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
591 let datetime = unix_nanos_to_datetime(unix_nanos)?;
592 let result = subtract_n_months(datetime, n)?;
593 let timestamp = match result.timestamp_nanos_opt() {
594 Some(ts) => ts,
595 None => anyhow::bail!("Timestamp out of range after subtracting {n} months"),
596 };
597
598 let nanos =
599 u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
600 Ok(UnixNanos::from(nanos))
601}
602
603pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
609 let datetime = unix_nanos_to_datetime(unix_nanos)?;
610 let result = add_n_months(datetime, n)?;
611 let timestamp = match result.timestamp_nanos_opt() {
612 Some(ts) => ts,
613 None => anyhow::bail!("Timestamp out of range after adding {n} months"),
614 };
615
616 let nanos =
617 u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
618 Ok(UnixNanos::from(nanos))
619}
620
621pub fn add_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
627 let months = n.checked_mul(12).ok_or_else(|| {
628 anyhow::anyhow!("Failed to add {n} years to {datetime}: month count overflow")
629 })?;
630
631 match datetime.checked_add_months(chrono::Months::new(months)) {
632 Some(result) => Ok(result),
633 None => anyhow::bail!("Failed to add {n} years to {datetime}"),
634 }
635}
636
637pub fn subtract_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
643 let months = n.checked_mul(12).ok_or_else(|| {
644 anyhow::anyhow!("Failed to subtract {n} years from {datetime}: month count overflow")
645 })?;
646
647 match datetime.checked_sub_months(chrono::Months::new(months)) {
648 Some(result) => Ok(result),
649 None => anyhow::bail!("Failed to subtract {n} years from {datetime}"),
650 }
651}
652
653pub fn add_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
659 let datetime = unix_nanos_to_datetime(unix_nanos)?;
660 let result = add_n_years(datetime, n)?;
661 let timestamp = match result.timestamp_nanos_opt() {
662 Some(ts) => ts,
663 None => anyhow::bail!("Timestamp out of range after adding {n} years"),
664 };
665
666 let nanos =
667 u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
668 Ok(UnixNanos::from(nanos))
669}
670
671pub fn subtract_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
677 let datetime = unix_nanos_to_datetime(unix_nanos)?;
678 let result = subtract_n_years(datetime, n)?;
679 let timestamp = match result.timestamp_nanos_opt() {
680 Some(ts) => ts,
681 None => anyhow::bail!("Timestamp out of range after subtracting {n} years"),
682 };
683
684 let nanos =
685 u64::try_from(timestamp).map_err(|_| anyhow::anyhow!("Negative timestamp not allowed"))?;
686 Ok(UnixNanos::from(nanos))
687}
688
689#[must_use]
693pub const fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
694 if month < 1 || month > 12 {
696 return None;
697 }
698
699 Some(match month {
701 2 => {
702 if is_leap_year(year) {
703 29
704 } else {
705 28
706 }
707 }
708 4 | 6 | 9 | 11 => 30,
709 _ => 31, })
711}
712
713#[must_use]
715pub const fn is_leap_year(year: i32) -> bool {
716 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
717}
718
719pub fn datetime_to_unix_nanos(value: Option<DateTime<Utc>>) -> Option<UnixNanos> {
721 value
722 .and_then(|dt| dt.timestamp_nanos_opt())
723 .and_then(|nanos| u64::try_from(nanos).ok())
724 .map(UnixNanos::from)
725}
726
727#[cfg(test)]
728#[expect(
729 clippy::float_cmp,
730 reason = "Exact float comparisons acceptable in tests"
731)]
732mod tests {
733 use chrono::{DateTime, SecondsFormat, TimeDelta, TimeZone, Timelike, Utc};
734 use proptest::prelude::*;
735 use rstest::rstest;
736
737 use super::*;
738
739 #[rstest]
740 #[case(0.0, 0)]
741 #[case(1.0, 1_000_000_000)]
742 #[case(1.1, 1_100_000_000)]
743 #[case(42.0, 42_000_000_000)]
744 #[case(0.000_123_5, 123_500)]
745 #[case(0.000_000_01, 10)]
746 #[case(0.000_000_001, 1)]
747 #[case(9.999_999_999, 9_999_999_999)]
748 fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
749 let result = secs_to_nanos(value).unwrap();
750 assert_eq!(result, expected);
751 }
752
753 #[rstest]
754 #[case(0.0, 0)]
755 #[case(1.0, 1_000)]
756 #[case(1.1, 1_100)]
757 #[case(42.0, 42_000)]
758 #[case(0.012_34, 12)]
759 #[case(0.001, 1)]
760 fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
761 let result = secs_to_millis(value).unwrap();
762 assert_eq!(result, expected);
763 }
764
765 #[rstest]
766 fn test_secs_to_nanos_unchecked_matches_checked() {
767 assert_eq!(secs_to_nanos_unchecked(1.1), secs_to_nanos(1.1).unwrap());
768 }
769
770 #[rstest]
771 fn test_secs_to_nanos_non_finite_errors() {
772 let err = secs_to_nanos(f64::NAN).unwrap_err();
773 assert!(err.to_string().contains("finite"));
774 }
775
776 #[rstest]
777 fn test_secs_to_nanos_overflow_errors() {
778 let err = secs_to_nanos(MAX_SECS_FOR_NANOS + 1.0).unwrap_err();
779 assert!(err.to_string().contains("exceeds"));
780 }
781
782 #[rstest]
783 fn test_secs_to_millis_non_finite_errors() {
784 let err = secs_to_millis(f64::INFINITY).unwrap_err();
785 assert!(err.to_string().contains("finite"));
786 }
787
788 #[rstest]
789 fn test_millis_to_nanos_overflow_errors() {
790 let err = millis_to_nanos(MAX_MILLIS_FOR_NANOS + 1.0).unwrap_err();
791 assert!(err.to_string().contains("exceeds"));
792 }
793
794 #[rstest]
795 fn test_millis_to_nanos_non_finite_errors() {
796 let err = millis_to_nanos(f64::NEG_INFINITY).unwrap_err();
797 assert!(err.to_string().contains("finite"));
798 }
799
800 #[rstest]
801 fn test_micros_to_nanos_non_finite_errors() {
802 let err = micros_to_nanos(f64::NAN).unwrap_err();
803 assert!(err.to_string().contains("finite"));
804 }
805
806 #[rstest]
807 #[case(0, 0)]
808 #[case(1, 60)]
809 #[case(5, 300)]
810 #[case(60, 3600)]
811 #[case(1440, 86400)]
812 fn test_mins_to_secs(#[case] mins: u64, #[case] expected: u64) {
813 assert_eq!(mins_to_secs(mins), expected);
814 }
815
816 #[rstest]
817 #[case(0, 0)]
818 #[case(1, 60_000_000_000)]
819 #[case(5, 300_000_000_000)]
820 #[case(60, 3_600_000_000_000)]
821 fn test_mins_to_nanos(#[case] mins: u64, #[case] expected: u64) {
822 assert_eq!(mins_to_nanos(mins), expected);
823 }
824
825 #[rstest]
826 fn test_micros_to_nanos_overflow_errors() {
827 let err = micros_to_nanos(MAX_MICROS_FOR_NANOS * 2.0).unwrap_err();
829 assert!(err.to_string().contains("exceeds"));
830 }
831
832 #[rstest]
833 fn test_secs_to_nanos_negative_infinity_errors() {
834 let result = secs_to_nanos(f64::NEG_INFINITY);
835 assert!(result.is_err());
836 }
837
838 #[rstest]
839 #[case(2024, 0)] #[case(2024, 13)] fn test_last_day_of_month_invalid_month(#[case] year: i32, #[case] month: u32) {
842 assert!(last_day_of_month(year, month).is_none());
843 }
844
845 #[rstest]
846 #[case(0.0, 0)]
847 #[case(1.0, 1_000_000)]
848 #[case(1.1, 1_100_000)]
849 #[case(42.0, 42_000_000)]
850 #[case(0.000_123_4, 123)]
851 #[case(0.000_01, 10)]
852 #[case(0.000_001, 1)]
853 #[case(9.999_999, 9_999_999)]
854 fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
855 let result = millis_to_nanos(value).unwrap();
856 assert_eq!(result, expected);
857 }
858
859 #[rstest]
860 fn test_millis_to_nanos_unchecked_matches_checked() {
861 assert_eq!(
862 millis_to_nanos_unchecked(1.1),
863 millis_to_nanos(1.1).unwrap()
864 );
865 }
866
867 #[rstest]
868 #[case(0.0, 0)]
869 #[case(1.0, 1_000)]
870 #[case(1.1, 1_100)]
871 #[case(42.0, 42_000)]
872 #[case(0.1234, 123)]
873 #[case(0.01, 10)]
874 #[case(0.001, 1)]
875 #[case(9.999, 9_999)]
876 fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
877 let result = micros_to_nanos(value).unwrap();
878 assert_eq!(result, expected);
879 }
880
881 #[rstest]
882 fn test_micros_to_nanos_unchecked_matches_checked() {
883 assert_eq!(
884 micros_to_nanos_unchecked(1.1),
885 micros_to_nanos(1.1).unwrap()
886 );
887 }
888
889 #[rstest]
890 #[case(0, 0.0)]
891 #[case(1, 1e-09)]
892 #[case(1_000_000_000, 1.0)]
893 #[case(42_897_123_111, 42.897_123_111)]
894 fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
895 let result = nanos_to_secs(value);
896 assert_eq!(result, expected);
897 }
898
899 #[rstest]
900 #[case(0, 0)]
901 #[case(1_000_000, 1)]
902 #[case(1_000_000_000, 1000)]
903 #[case(42_897_123_111, 42897)]
904 fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
905 let result = nanos_to_millis(value);
906 assert_eq!(result, expected);
907 }
908
909 #[rstest]
910 #[case(0, 0)]
911 #[case(1_000, 1)]
912 #[case(1_000_000_000, 1_000_000)]
913 #[case(42_897_123, 42_897)]
914 fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
915 let result = nanos_to_micros(value);
916 assert_eq!(result, expected);
917 }
918
919 #[rstest]
920 #[case(0, "1970-01-01T00:00:00.000000000Z")] #[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(951_782_400_000_000_000, "2000-02-29T00:00:00.000000000Z")] #[case(1_609_459_199_999_999_999, "2020-12-31T23:59:59.999999999Z")] #[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) {
929 let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
930 assert_eq!(result, expected);
931 }
932
933 #[rstest]
934 #[case(0)]
935 #[case(1)]
936 #[case(951_782_400_123_456_789)]
937 #[case(1_609_459_199_999_999_999)]
938 #[case(i64::MAX as u64)]
939 fn test_unix_nanos_to_iso8601_matches_chrono_oracle(#[case] nanos: u64) {
940 let nanos_i64 = i64::try_from(nanos).expect("oracle cases stay within chrono range");
941 let expected =
942 DateTime::from_timestamp_nanos(nanos_i64).to_rfc3339_opts(SecondsFormat::Nanos, true);
943 let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
944 assert_eq!(result, expected);
945 }
946
947 #[rstest]
948 #[case((i64::MAX as u64) + 1)]
949 #[case(u64::MAX)]
950 fn test_unix_nanos_to_iso8601_falls_back_when_chrono_range_exceeded(#[case] nanos: u64) {
951 let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
952 assert_eq!(result, nanos.to_string());
953 }
954
955 #[rstest]
956 #[case(0, "1970-01-01T00:00:00.000Z")] #[case(1_000_000, "1970-01-01T00:00:00.001Z")] #[case(1_000_000_000, "1970-01-01T00:00:01.000Z")] #[case(951_782_400_123_456_789, "2000-02-29T00:00:00.123Z")] #[case(1_609_459_199_999_999_999, "2020-12-31T23:59:59.999Z")] #[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) {
963 let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
964 assert_eq!(result, expected);
965 }
966
967 #[rstest]
968 #[case(0)]
969 #[case(951_782_400_123_456_789)]
970 #[case(1_609_459_199_999_999_999)]
971 #[case(i64::MAX as u64)]
972 fn test_unix_nanos_to_iso8601_millis_matches_chrono_oracle(#[case] nanos: u64) {
973 let nanos_i64 = i64::try_from(nanos).expect("oracle cases stay within chrono range");
974 let expected =
975 DateTime::from_timestamp_nanos(nanos_i64).to_rfc3339_opts(SecondsFormat::Millis, true);
976 let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
977 assert_eq!(result, expected);
978 }
979
980 #[rstest]
981 #[case((i64::MAX as u64) + 1)]
982 #[case(u64::MAX)]
983 fn test_unix_nanos_to_iso8601_millis_falls_back_when_chrono_range_exceeded(#[case] nanos: u64) {
984 let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
985 assert_eq!(result, nanos.to_string());
986 }
987
988 proptest! {
991 #[rstest]
992 fn prop_unix_nanos_to_iso8601_matches_chrono(nanos in 0u64..=i64::MAX as u64) {
993 let nanos_i64 = i64::try_from(nanos).expect("nanos within i64 range");
994 let expected = DateTime::from_timestamp_nanos(nanos_i64)
995 .to_rfc3339_opts(SecondsFormat::Nanos, true);
996 let actual = unix_nanos_to_iso8601(UnixNanos::from(nanos));
997 prop_assert_eq!(actual, expected);
998 }
999
1000 #[rstest]
1001 fn prop_unix_nanos_to_iso8601_millis_matches_chrono(nanos in 0u64..=i64::MAX as u64) {
1002 let nanos_i64 = i64::try_from(nanos).expect("nanos within i64 range");
1003 let expected = DateTime::from_timestamp_nanos(nanos_i64)
1004 .to_rfc3339_opts(SecondsFormat::Millis, true);
1005 let actual = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
1006 prop_assert_eq!(actual, expected);
1007 }
1008
1009 #[rstest]
1010 fn prop_unix_nanos_to_iso8601_falls_back_above_chrono_range(
1011 nanos in (i64::MAX as u64 + 1)..=u64::MAX,
1012 ) {
1013 let raw = nanos.to_string();
1014 prop_assert_eq!(unix_nanos_to_iso8601(UnixNanos::from(nanos)), raw.as_str());
1015 prop_assert_eq!(unix_nanos_to_iso8601_millis(UnixNanos::from(nanos)), raw.as_str());
1016 }
1017 }
1018
1019 #[rstest]
1020 #[case(2023, 12, 15, 1_702_598_400_000_000_000)] #[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(
1025 #[case] year: i32,
1026 #[case] month: u32,
1027 #[case] day: u32,
1028 #[case] expected: u64,
1029 ) {
1030 let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
1031 assert_eq!(result, expected);
1032 }
1033
1034 #[rstest]
1035 fn test_last_closest_weekday_nanos_with_invalid_date() {
1036 let result = last_weekday_nanos(2023, 4, 31);
1037 assert!(result.is_err());
1038 }
1039
1040 #[rstest]
1041 fn test_last_closest_weekday_nanos_with_nonexistent_date() {
1042 let result = last_weekday_nanos(2023, 2, 30);
1043 assert!(result.is_err());
1044 }
1045
1046 #[rstest]
1047 fn test_last_closest_weekday_nanos_with_invalid_conversion() {
1048 let result = last_weekday_nanos(9999, 12, 31);
1049 assert!(result.is_err());
1050 }
1051
1052 #[rstest]
1053 fn test_is_within_last_24_hours_when_now() {
1054 let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
1055 assert!(is_within_last_24_hours(UnixNanos::from(now_ns.cast_unsigned())).unwrap());
1056 }
1057
1058 #[rstest]
1059 fn test_is_within_last_24_hours_when_two_days_ago() {
1060 let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
1061 .timestamp_nanos_opt()
1062 .unwrap();
1063 assert!(!is_within_last_24_hours(UnixNanos::from(past_ns.cast_unsigned())).unwrap());
1064 }
1065
1066 #[rstest]
1067 fn test_is_within_last_24_hours_when_future() {
1068 let future_ns = (Utc::now() + TimeDelta::try_hours(1).unwrap())
1070 .timestamp_nanos_opt()
1071 .unwrap();
1072 assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
1073
1074 let future_ns = (Utc::now() + TimeDelta::try_days(1).unwrap())
1076 .timestamp_nanos_opt()
1077 .unwrap();
1078 assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
1079 }
1080
1081 #[rstest]
1082 #[case(Utc.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap())] #[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(
1087 #[case] input: DateTime<Utc>,
1088 #[case] months: u32,
1089 #[case] expected: DateTime<Utc>,
1090 ) {
1091 let result = subtract_n_months(input, months).unwrap();
1092 assert_eq!(result, expected);
1093 }
1094
1095 #[rstest]
1096 #[case(Utc.with_ymd_and_hms(2023, 2, 28, 12, 0, 0).unwrap(), 1, Utc.with_ymd_and_hms(2023, 3, 28, 12, 0, 0).unwrap())] #[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(
1101 #[case] input: DateTime<Utc>,
1102 #[case] months: u32,
1103 #[case] expected: DateTime<Utc>,
1104 ) {
1105 let result = add_n_months(input, months).unwrap();
1106 assert_eq!(result, expected);
1107 }
1108
1109 #[rstest]
1110 fn test_add_n_years_overflow() {
1111 let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1112 let err = add_n_years(datetime, u32::MAX).unwrap_err();
1113 assert!(err.to_string().contains("month count overflow"));
1114 }
1115
1116 #[rstest]
1117 fn test_subtract_n_years_overflow() {
1118 let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1119 let err = subtract_n_years(datetime, u32::MAX).unwrap_err();
1120 assert!(err.to_string().contains("month count overflow"));
1121 }
1122
1123 #[rstest]
1124 fn test_add_n_years_nanos_overflow() {
1125 let nanos = UnixNanos::from(0);
1126 let err = add_n_years_nanos(nanos, u32::MAX).unwrap_err();
1127 assert!(err.to_string().contains("month count overflow"));
1128 }
1129
1130 #[rstest]
1131 #[case(2024, 2, 29)] #[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) {
1136 let result = last_day_of_month(year, month).unwrap();
1137 assert_eq!(result, expected);
1138 }
1139
1140 #[rstest]
1141 #[case(2024, true)] #[case(1900, false)] #[case(2000, true)] #[case(2023, false)] fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
1146 let result = is_leap_year(year);
1147 assert_eq!(result, expected);
1148 }
1149
1150 #[rstest]
1151 #[case("1970-01-01T00:00:00.000000000Z", 0)] #[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) {
1160 let result = iso8601_to_unix_nanos(input).unwrap();
1161 assert_eq!(result.as_u64(), expected);
1162 }
1163
1164 #[rstest]
1165 #[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) {
1170 let result = iso8601_to_unix_nanos(input);
1171 assert!(result.is_err());
1172 }
1173
1174 #[rstest]
1175 fn test_iso8601_roundtrip() {
1176 let original_nanos = UnixNanos::from(1_707_577_123_456_789_000);
1177 let iso8601_string = unix_nanos_to_iso8601(original_nanos);
1178 let parsed_nanos = iso8601_to_unix_nanos(&iso8601_string).unwrap();
1179 assert_eq!(parsed_nanos, original_nanos);
1180 }
1181
1182 #[rstest]
1183 fn test_add_n_years_nanos_normal_case() {
1184 let start = UnixNanos::from(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap());
1186 let result = add_n_years_nanos(start, 1).unwrap();
1187 let expected = UnixNanos::from(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap());
1188 assert_eq!(result, expected);
1189 }
1190
1191 #[rstest]
1192 fn test_add_n_years_nanos_prevents_negative_timestamp() {
1193 let start = UnixNanos::from(0); let result = add_n_years_nanos(start, 1);
1199 assert!(result.is_ok());
1200 }
1201
1202 #[rstest]
1203 fn test_datetime_to_unix_nanos_at_epoch() {
1204 let epoch = Utc.timestamp_opt(0, 0).unwrap();
1206 let result = datetime_to_unix_nanos(Some(epoch));
1207 assert_eq!(result, Some(UnixNanos::from(0)));
1208 }
1209
1210 #[rstest]
1211 fn test_datetime_to_unix_nanos_typical_datetime() {
1212 let dt = Utc
1213 .with_ymd_and_hms(2024, 1, 15, 13, 30, 45)
1214 .unwrap()
1215 .with_nanosecond(123_456_789)
1216 .unwrap();
1217 let result = datetime_to_unix_nanos(Some(dt));
1218
1219 assert!(result.is_some());
1221 assert_eq!(result.unwrap().as_u64(), 1_705_325_445_123_456_789);
1222 }
1223
1224 #[rstest]
1225 fn test_datetime_to_unix_nanos_before_epoch() {
1226 let before_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 59).unwrap();
1229 let result = datetime_to_unix_nanos(Some(before_epoch));
1230 assert_eq!(result, None);
1231 }
1232
1233 #[rstest]
1234 fn test_datetime_to_unix_nanos_one_second_after_epoch() {
1235 let dt = Utc.timestamp_opt(1, 0).unwrap();
1237 let result = datetime_to_unix_nanos(Some(dt));
1238 assert_eq!(result, Some(UnixNanos::from(1_000_000_000)));
1239 }
1240
1241 #[rstest]
1242 fn test_datetime_to_unix_nanos_with_subsecond_precision() {
1243 let dt = Utc.timestamp_opt(0, 1_000).unwrap(); let result = datetime_to_unix_nanos(Some(dt));
1246 assert_eq!(result, Some(UnixNanos::from(1_000)));
1247 }
1248
1249 #[rstest]
1250 fn test_nanos_helpers_return_err_for_values_above_i64_max() {
1251 let large = UnixNanos::from(u64::MAX);
1252 assert!(subtract_n_months_nanos(large, 1).is_err());
1253 assert!(add_n_months_nanos(large, 1).is_err());
1254 assert!(add_n_years_nanos(large, 1).is_err());
1255 assert!(subtract_n_years_nanos(large, 1).is_err());
1256 }
1257
1258 #[rstest]
1259 fn test_subtract_n_months_nanos_pre_epoch_result_errors() {
1260 let epoch = UnixNanos::from(0);
1261 let err = subtract_n_months_nanos(epoch, 1).unwrap_err();
1262 assert_eq!(err.to_string(), "Negative timestamp not allowed");
1263 }
1264
1265 #[rstest]
1266 fn test_subtract_n_years_nanos_pre_epoch_result_errors() {
1267 let epoch = UnixNanos::from(0);
1268 let err = subtract_n_years_nanos(epoch, 1).unwrap_err();
1269 assert_eq!(err.to_string(), "Negative timestamp not allowed");
1270 }
1271
1272 #[rstest]
1273 fn test_subtract_n_months_nanos_at_epoch_boundary() {
1274 let epoch = UnixNanos::from(0);
1275 assert_eq!(subtract_n_months_nanos(epoch, 0).unwrap(), epoch);
1276 }
1277}