1use std::convert::TryFrom;
18
19use chrono::{DateTime, Datelike, NaiveDate, SecondsFormat, 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;
31
32pub const NANOSECONDS_IN_MICROSECOND: u64 = 1_000;
34
35pub const NANOSECONDS_IN_MINUTE: u64 = 60 * NANOSECONDS_IN_SECOND;
37
38pub const NANOSECONDS_IN_DAY: u64 = 24 * 60 * NANOSECONDS_IN_MINUTE;
40
41pub const SECONDS_IN_MINUTE: u64 = 60;
43
44pub const SECONDS_IN_HOUR: u64 = 60 * SECONDS_IN_MINUTE;
46
47pub const SECONDS_IN_DAY: u64 = 24 * SECONDS_IN_HOUR;
49
50#[expect(
52 clippy::cast_precision_loss,
53 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
54)]
55const MAX_SECS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_SECOND as f64;
56#[expect(
58 clippy::cast_precision_loss,
59 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
60)]
61const MAX_SECS_FOR_MILLIS: f64 = u64::MAX as f64 / MILLISECONDS_IN_SECOND as f64;
62#[expect(
64 clippy::cast_precision_loss,
65 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
66)]
67const MAX_MILLIS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MILLISECOND as f64;
68#[expect(
70 clippy::cast_precision_loss,
71 reason = "deriving a max-representable bound; f64 precision loss is part of the semantics"
72)]
73const MAX_MICROS_FOR_NANOS: f64 = u64::MAX as f64 / NANOSECONDS_IN_MICROSECOND as f64;
74
75const _: () = {
77 assert!(NANOSECONDS_IN_SECOND == 1_000_000_000);
78 assert!(NANOSECONDS_IN_MILLISECOND == 1_000_000);
79 assert!(NANOSECONDS_IN_MICROSECOND == 1_000);
80 assert!(MILLISECONDS_IN_SECOND == 1_000);
81 assert!(NANOSECONDS_IN_SECOND == MILLISECONDS_IN_SECOND * NANOSECONDS_IN_MILLISECOND);
82 assert!(NANOSECONDS_IN_MILLISECOND == NANOSECONDS_IN_MICROSECOND * 1_000);
83 assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MILLISECOND == 1_000);
84 assert!(NANOSECONDS_IN_SECOND / NANOSECONDS_IN_MICROSECOND == 1_000_000);
85 assert!(SECONDS_IN_MINUTE == 60);
86 assert!(SECONDS_IN_HOUR == 3_600);
87 assert!(SECONDS_IN_DAY == 86_400);
88 assert!(NANOSECONDS_IN_MINUTE == 60 * NANOSECONDS_IN_SECOND);
89 assert!(NANOSECONDS_IN_DAY == 24 * 60 * NANOSECONDS_IN_MINUTE);
90};
91
92#[inline]
93fn unix_nanos_to_datetime(unix_nanos: UnixNanos) -> anyhow::Result<DateTime<Utc>> {
94 let nanos_i64 = i64::try_from(unix_nanos.as_u64()).map_err(|_| {
95 anyhow::anyhow!(
96 "UnixNanos value {} exceeds maximum representable datetime (i64::MAX)",
97 unix_nanos.as_u64()
98 )
99 })?;
100 Ok(DateTime::from_timestamp_nanos(nanos_i64))
101}
102
103pub const WEEKDAYS: [Weekday; 5] = [
105 Weekday::Mon,
106 Weekday::Tue,
107 Weekday::Wed,
108 Weekday::Thu,
109 Weekday::Fri,
110];
111
112#[expect(
118 clippy::cast_possible_truncation,
119 clippy::cast_sign_loss,
120 clippy::cast_precision_loss,
121 reason = "Intentional for unit conversion, may lose precision after clamping"
122)]
123pub fn secs_to_nanos(secs: f64) -> anyhow::Result<u64> {
124 anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
125 if secs <= 0.0 {
126 return Ok(0);
127 }
128 anyhow::ensure!(
129 secs <= MAX_SECS_FOR_NANOS,
130 "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_NANOS}"
131 );
132 let nanos = secs * NANOSECONDS_IN_SECOND as f64;
133 Ok(nanos.trunc() as u64)
134}
135
136#[expect(
142 clippy::cast_possible_truncation,
143 clippy::cast_sign_loss,
144 clippy::cast_precision_loss,
145 reason = "Intentional for unit conversion, may lose precision after clamping"
146)]
147pub fn secs_to_millis(secs: f64) -> anyhow::Result<u64> {
148 anyhow::ensure!(secs.is_finite(), "seconds must be finite, was {secs}");
149 if secs <= 0.0 {
150 return Ok(0);
151 }
152 anyhow::ensure!(
153 secs <= MAX_SECS_FOR_MILLIS,
154 "seconds {secs} exceeds maximum representable value {MAX_SECS_FOR_MILLIS}"
155 );
156 let millis = secs * MILLISECONDS_IN_SECOND as f64;
157 Ok(millis.trunc() as u64)
158}
159
160#[must_use]
169pub fn secs_to_nanos_unchecked(secs: f64) -> u64 {
170 secs_to_nanos(secs).expect("secs_to_nanos_unchecked: invalid or overflowing input")
171}
172
173#[must_use]
175pub const fn mins_to_secs(mins: u64) -> u64 {
176 mins * SECONDS_IN_MINUTE
177}
178
179#[must_use]
181pub const fn mins_to_nanos(mins: u64) -> u64 {
182 mins * NANOSECONDS_IN_MINUTE
183}
184
185#[expect(
194 clippy::cast_possible_truncation,
195 clippy::cast_sign_loss,
196 clippy::cast_precision_loss,
197 reason = "Intentional for unit conversion, may lose precision after clamping"
198)]
199pub fn millis_to_nanos(millis: f64) -> anyhow::Result<u64> {
200 anyhow::ensure!(
201 millis.is_finite(),
202 "milliseconds must be finite, was {millis}"
203 );
204
205 if millis <= 0.0 {
206 return Ok(0);
207 }
208 anyhow::ensure!(
209 millis <= MAX_MILLIS_FOR_NANOS,
210 "milliseconds {millis} exceeds maximum representable value {MAX_MILLIS_FOR_NANOS}"
211 );
212 let nanos = millis * NANOSECONDS_IN_MILLISECOND as f64;
213 Ok(nanos.trunc() as u64)
214}
215
216#[must_use]
222pub fn millis_to_nanos_unchecked(millis: f64) -> u64 {
223 millis_to_nanos(millis).expect("millis_to_nanos_unchecked: invalid or overflowing input")
224}
225
226#[expect(
235 clippy::cast_possible_truncation,
236 clippy::cast_sign_loss,
237 clippy::cast_precision_loss,
238 reason = "Intentional for unit conversion, may lose precision after clamping"
239)]
240pub fn micros_to_nanos(micros: f64) -> anyhow::Result<u64> {
241 anyhow::ensure!(
242 micros.is_finite(),
243 "microseconds must be finite, was {micros}"
244 );
245
246 if micros <= 0.0 {
247 return Ok(0);
248 }
249 anyhow::ensure!(
250 micros <= MAX_MICROS_FOR_NANOS,
251 "microseconds {micros} exceeds maximum representable value {MAX_MICROS_FOR_NANOS}"
252 );
253 let nanos = micros * NANOSECONDS_IN_MICROSECOND as f64;
254 Ok(nanos.trunc() as u64)
255}
256
257#[must_use]
263pub fn micros_to_nanos_unchecked(micros: f64) -> u64 {
264 micros_to_nanos(micros).expect("micros_to_nanos_unchecked: invalid or overflowing input")
265}
266
267#[expect(
272 clippy::cast_precision_loss,
273 reason = "Precision loss acceptable for time conversion"
274)]
275#[must_use]
276pub fn nanos_to_secs(nanos: u64) -> f64 {
277 let seconds = nanos / NANOSECONDS_IN_SECOND;
278 let rem_nanos = nanos % NANOSECONDS_IN_SECOND;
279 (seconds as f64) + (rem_nanos as f64) / (NANOSECONDS_IN_SECOND as f64)
280}
281
282#[must_use]
284pub const fn nanos_to_millis(nanos: u64) -> u64 {
285 nanos / NANOSECONDS_IN_MILLISECOND
286}
287
288#[must_use]
290pub const fn nanos_to_micros(nanos: u64) -> u64 {
291 nanos / NANOSECONDS_IN_MICROSECOND
292}
293
294#[inline]
299#[must_use]
300pub fn unix_nanos_to_iso8601(unix_nanos: UnixNanos) -> String {
301 match unix_nanos_to_datetime(unix_nanos) {
302 Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Nanos, true),
303 Err(_) => unix_nanos.as_u64().to_string(),
304 }
305}
306
307#[inline]
330pub fn iso8601_to_unix_nanos(date_string: &str) -> anyhow::Result<UnixNanos> {
331 date_string
332 .parse::<UnixNanos>()
333 .map_err(|e| anyhow::anyhow!("Failed to parse ISO 8601 string '{date_string}': {e}"))
334}
335
336#[inline]
342#[must_use]
343pub fn unix_nanos_to_iso8601_millis(unix_nanos: UnixNanos) -> String {
344 match unix_nanos_to_datetime(unix_nanos) {
345 Ok(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
346 Err(_) => unix_nanos.as_u64().to_string(),
347 }
348}
349
350#[must_use]
352pub const fn floor_to_nearest_microsecond(unix_nanos: u64) -> u64 {
353 (unix_nanos / NANOSECONDS_IN_MICROSECOND) * NANOSECONDS_IN_MICROSECOND
354}
355
356pub fn last_weekday_nanos(year: i32, month: u32, day: u32) -> anyhow::Result<UnixNanos> {
362 let date =
363 NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
364 let current_weekday = date.weekday().number_from_monday();
365
366 let offset = i64::from(match current_weekday {
368 1..=5 => 0, 6 => 1, _ => 2, });
372 let last_closest = date - TimeDelta::days(offset);
374
375 let unix_timestamp_ns = last_closest
377 .and_hms_nano_opt(0, 0, 0, 0)
378 .ok_or_else(|| anyhow::anyhow!("Failed `and_hms_nano_opt`"))?;
379
380 let raw_ns = unix_timestamp_ns
382 .and_utc()
383 .timestamp_nanos_opt()
384 .ok_or_else(|| anyhow::anyhow!("Failed `timestamp_nanos_opt`"))?;
385 let ns_u64 =
386 u64::try_from(raw_ns).map_err(|_| anyhow::anyhow!("Negative timestamp: {raw_ns}"))?;
387 Ok(UnixNanos::from(ns_u64))
388}
389
390pub fn is_within_last_24_hours(timestamp_ns: UnixNanos) -> anyhow::Result<bool> {
396 let timestamp_ns = timestamp_ns.as_u64();
400 let now_ns = nanos_since_unix_epoch();
401
402 if timestamp_ns > now_ns {
404 return Ok(false);
405 }
406
407 Ok(now_ns - timestamp_ns <= NANOSECONDS_IN_DAY)
408}
409
410pub fn subtract_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
416 match datetime.checked_sub_months(chrono::Months::new(n)) {
417 Some(result) => Ok(result),
418 None => anyhow::bail!("Failed to subtract {n} months from {datetime}"),
419 }
420}
421
422pub fn add_n_months(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
428 match datetime.checked_add_months(chrono::Months::new(n)) {
429 Some(result) => Ok(result),
430 None => anyhow::bail!("Failed to add {n} months to {datetime}"),
431 }
432}
433
434#[expect(
440 clippy::cast_sign_loss,
441 reason = "explicit `if timestamp < 0` guard before the cast"
442)]
443pub fn subtract_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
444 let datetime = unix_nanos_to_datetime(unix_nanos)?;
445 let result = subtract_n_months(datetime, n)?;
446 let timestamp = match result.timestamp_nanos_opt() {
447 Some(ts) => ts,
448 None => anyhow::bail!("Timestamp out of range after subtracting {n} months"),
449 };
450
451 if timestamp < 0 {
452 anyhow::bail!("Negative timestamp not allowed");
453 }
454
455 Ok(UnixNanos::from(timestamp as u64))
456}
457
458#[expect(
464 clippy::cast_sign_loss,
465 reason = "explicit `if timestamp < 0` guard before the cast"
466)]
467pub fn add_n_months_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
468 let datetime = unix_nanos_to_datetime(unix_nanos)?;
469 let result = add_n_months(datetime, n)?;
470 let timestamp = match result.timestamp_nanos_opt() {
471 Some(ts) => ts,
472 None => anyhow::bail!("Timestamp out of range after adding {n} months"),
473 };
474
475 if timestamp < 0 {
476 anyhow::bail!("Negative timestamp not allowed");
477 }
478
479 Ok(UnixNanos::from(timestamp as u64))
480}
481
482pub fn add_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
488 let months = n.checked_mul(12).ok_or_else(|| {
489 anyhow::anyhow!("Failed to add {n} years to {datetime}: month count overflow")
490 })?;
491
492 match datetime.checked_add_months(chrono::Months::new(months)) {
493 Some(result) => Ok(result),
494 None => anyhow::bail!("Failed to add {n} years to {datetime}"),
495 }
496}
497
498pub fn subtract_n_years(datetime: DateTime<Utc>, n: u32) -> anyhow::Result<DateTime<Utc>> {
504 let months = n.checked_mul(12).ok_or_else(|| {
505 anyhow::anyhow!("Failed to subtract {n} years from {datetime}: month count overflow")
506 })?;
507
508 match datetime.checked_sub_months(chrono::Months::new(months)) {
509 Some(result) => Ok(result),
510 None => anyhow::bail!("Failed to subtract {n} years from {datetime}"),
511 }
512}
513
514#[expect(
520 clippy::cast_sign_loss,
521 reason = "explicit `if timestamp < 0` guard before the cast"
522)]
523pub fn add_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
524 let datetime = unix_nanos_to_datetime(unix_nanos)?;
525 let result = add_n_years(datetime, n)?;
526 let timestamp = match result.timestamp_nanos_opt() {
527 Some(ts) => ts,
528 None => anyhow::bail!("Timestamp out of range after adding {n} years"),
529 };
530
531 if timestamp < 0 {
532 anyhow::bail!("Negative timestamp not allowed");
533 }
534
535 Ok(UnixNanos::from(timestamp as u64))
536}
537
538#[expect(
544 clippy::cast_sign_loss,
545 reason = "explicit `if timestamp < 0` guard before the cast"
546)]
547pub fn subtract_n_years_nanos(unix_nanos: UnixNanos, n: u32) -> anyhow::Result<UnixNanos> {
548 let datetime = unix_nanos_to_datetime(unix_nanos)?;
549 let result = subtract_n_years(datetime, n)?;
550 let timestamp = match result.timestamp_nanos_opt() {
551 Some(ts) => ts,
552 None => anyhow::bail!("Timestamp out of range after subtracting {n} years"),
553 };
554
555 if timestamp < 0 {
556 anyhow::bail!("Negative timestamp not allowed");
557 }
558
559 Ok(UnixNanos::from(timestamp as u64))
560}
561
562#[must_use]
566pub const fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
567 if month < 1 || month > 12 {
569 return None;
570 }
571
572 Some(match month {
574 2 => {
575 if is_leap_year(year) {
576 29
577 } else {
578 28
579 }
580 }
581 4 | 6 | 9 | 11 => 30,
582 _ => 31, })
584}
585
586#[must_use]
588pub const fn is_leap_year(year: i32) -> bool {
589 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
590}
591
592pub fn datetime_to_unix_nanos(value: Option<DateTime<Utc>>) -> Option<UnixNanos> {
594 value
595 .and_then(|dt| dt.timestamp_nanos_opt())
596 .and_then(|nanos| u64::try_from(nanos).ok())
597 .map(UnixNanos::from)
598}
599
600#[cfg(test)]
601#[expect(
602 clippy::float_cmp,
603 reason = "Exact float comparisons acceptable in tests"
604)]
605mod tests {
606 use chrono::{DateTime, TimeDelta, TimeZone, Timelike, Utc};
607 use rstest::rstest;
608
609 use super::*;
610
611 #[rstest]
612 #[case(0.0, 0)]
613 #[case(1.0, 1_000_000_000)]
614 #[case(1.1, 1_100_000_000)]
615 #[case(42.0, 42_000_000_000)]
616 #[case(0.000_123_5, 123_500)]
617 #[case(0.000_000_01, 10)]
618 #[case(0.000_000_001, 1)]
619 #[case(9.999_999_999, 9_999_999_999)]
620 fn test_secs_to_nanos(#[case] value: f64, #[case] expected: u64) {
621 let result = secs_to_nanos(value).unwrap();
622 assert_eq!(result, expected);
623 }
624
625 #[rstest]
626 #[case(0.0, 0)]
627 #[case(1.0, 1_000)]
628 #[case(1.1, 1_100)]
629 #[case(42.0, 42_000)]
630 #[case(0.012_34, 12)]
631 #[case(0.001, 1)]
632 fn test_secs_to_millis(#[case] value: f64, #[case] expected: u64) {
633 let result = secs_to_millis(value).unwrap();
634 assert_eq!(result, expected);
635 }
636
637 #[rstest]
638 fn test_secs_to_nanos_unchecked_matches_checked() {
639 assert_eq!(secs_to_nanos_unchecked(1.1), secs_to_nanos(1.1).unwrap());
640 }
641
642 #[rstest]
643 fn test_secs_to_nanos_non_finite_errors() {
644 let err = secs_to_nanos(f64::NAN).unwrap_err();
645 assert!(err.to_string().contains("finite"));
646 }
647
648 #[rstest]
649 fn test_secs_to_nanos_overflow_errors() {
650 let err = secs_to_nanos(MAX_SECS_FOR_NANOS + 1.0).unwrap_err();
651 assert!(err.to_string().contains("exceeds"));
652 }
653
654 #[rstest]
655 fn test_secs_to_millis_non_finite_errors() {
656 let err = secs_to_millis(f64::INFINITY).unwrap_err();
657 assert!(err.to_string().contains("finite"));
658 }
659
660 #[rstest]
661 fn test_millis_to_nanos_overflow_errors() {
662 let err = millis_to_nanos(MAX_MILLIS_FOR_NANOS + 1.0).unwrap_err();
663 assert!(err.to_string().contains("exceeds"));
664 }
665
666 #[rstest]
667 fn test_millis_to_nanos_non_finite_errors() {
668 let err = millis_to_nanos(f64::NEG_INFINITY).unwrap_err();
669 assert!(err.to_string().contains("finite"));
670 }
671
672 #[rstest]
673 fn test_micros_to_nanos_non_finite_errors() {
674 let err = micros_to_nanos(f64::NAN).unwrap_err();
675 assert!(err.to_string().contains("finite"));
676 }
677
678 #[rstest]
679 #[case(0, 0)]
680 #[case(1, 60)]
681 #[case(5, 300)]
682 #[case(60, 3600)]
683 #[case(1440, 86400)]
684 fn test_mins_to_secs(#[case] mins: u64, #[case] expected: u64) {
685 assert_eq!(mins_to_secs(mins), expected);
686 }
687
688 #[rstest]
689 #[case(0, 0)]
690 #[case(1, 60_000_000_000)]
691 #[case(5, 300_000_000_000)]
692 #[case(60, 3_600_000_000_000)]
693 fn test_mins_to_nanos(#[case] mins: u64, #[case] expected: u64) {
694 assert_eq!(mins_to_nanos(mins), expected);
695 }
696
697 #[rstest]
698 fn test_micros_to_nanos_overflow_errors() {
699 let err = micros_to_nanos(MAX_MICROS_FOR_NANOS * 2.0).unwrap_err();
701 assert!(err.to_string().contains("exceeds"));
702 }
703
704 #[rstest]
705 fn test_secs_to_nanos_negative_infinity_errors() {
706 let result = secs_to_nanos(f64::NEG_INFINITY);
707 assert!(result.is_err());
708 }
709
710 #[rstest]
711 #[case(2024, 0)] #[case(2024, 13)] fn test_last_day_of_month_invalid_month(#[case] year: i32, #[case] month: u32) {
714 assert!(last_day_of_month(year, month).is_none());
715 }
716
717 #[rstest]
718 #[case(0.0, 0)]
719 #[case(1.0, 1_000_000)]
720 #[case(1.1, 1_100_000)]
721 #[case(42.0, 42_000_000)]
722 #[case(0.000_123_4, 123)]
723 #[case(0.000_01, 10)]
724 #[case(0.000_001, 1)]
725 #[case(9.999_999, 9_999_999)]
726 fn test_millis_to_nanos(#[case] value: f64, #[case] expected: u64) {
727 let result = millis_to_nanos(value).unwrap();
728 assert_eq!(result, expected);
729 }
730
731 #[rstest]
732 fn test_millis_to_nanos_unchecked_matches_checked() {
733 assert_eq!(
734 millis_to_nanos_unchecked(1.1),
735 millis_to_nanos(1.1).unwrap()
736 );
737 }
738
739 #[rstest]
740 #[case(0.0, 0)]
741 #[case(1.0, 1_000)]
742 #[case(1.1, 1_100)]
743 #[case(42.0, 42_000)]
744 #[case(0.1234, 123)]
745 #[case(0.01, 10)]
746 #[case(0.001, 1)]
747 #[case(9.999, 9_999)]
748 fn test_micros_to_nanos(#[case] value: f64, #[case] expected: u64) {
749 let result = micros_to_nanos(value).unwrap();
750 assert_eq!(result, expected);
751 }
752
753 #[rstest]
754 fn test_micros_to_nanos_unchecked_matches_checked() {
755 assert_eq!(
756 micros_to_nanos_unchecked(1.1),
757 micros_to_nanos(1.1).unwrap()
758 );
759 }
760
761 #[rstest]
762 #[case(0, 0.0)]
763 #[case(1, 1e-09)]
764 #[case(1_000_000_000, 1.0)]
765 #[case(42_897_123_111, 42.897_123_111)]
766 fn test_nanos_to_secs(#[case] value: u64, #[case] expected: f64) {
767 let result = nanos_to_secs(value);
768 assert_eq!(result, expected);
769 }
770
771 #[rstest]
772 #[case(0, 0)]
773 #[case(1_000_000, 1)]
774 #[case(1_000_000_000, 1000)]
775 #[case(42_897_123_111, 42897)]
776 fn test_nanos_to_millis(#[case] value: u64, #[case] expected: u64) {
777 let result = nanos_to_millis(value);
778 assert_eq!(result, expected);
779 }
780
781 #[rstest]
782 #[case(0, 0)]
783 #[case(1_000, 1)]
784 #[case(1_000_000_000, 1_000_000)]
785 #[case(42_897_123, 42_897)]
786 fn test_nanos_to_micros(#[case] value: u64, #[case] expected: u64) {
787 let result = nanos_to_micros(value);
788 assert_eq!(result, expected);
789 }
790
791 #[rstest]
792 #[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) {
799 let result = unix_nanos_to_iso8601(UnixNanos::from(nanos));
800 assert_eq!(result, expected);
801 }
802
803 #[rstest]
804 #[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) {
809 let result = unix_nanos_to_iso8601_millis(UnixNanos::from(nanos));
810 assert_eq!(result, expected);
811 }
812
813 #[rstest]
814 #[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(
819 #[case] year: i32,
820 #[case] month: u32,
821 #[case] day: u32,
822 #[case] expected: u64,
823 ) {
824 let result = last_weekday_nanos(year, month, day).unwrap().as_u64();
825 assert_eq!(result, expected);
826 }
827
828 #[rstest]
829 fn test_last_closest_weekday_nanos_with_invalid_date() {
830 let result = last_weekday_nanos(2023, 4, 31);
831 assert!(result.is_err());
832 }
833
834 #[rstest]
835 fn test_last_closest_weekday_nanos_with_nonexistent_date() {
836 let result = last_weekday_nanos(2023, 2, 30);
837 assert!(result.is_err());
838 }
839
840 #[rstest]
841 fn test_last_closest_weekday_nanos_with_invalid_conversion() {
842 let result = last_weekday_nanos(9999, 12, 31);
843 assert!(result.is_err());
844 }
845
846 #[rstest]
847 fn test_is_within_last_24_hours_when_now() {
848 let now_ns = Utc::now().timestamp_nanos_opt().unwrap();
849 assert!(is_within_last_24_hours(UnixNanos::from(now_ns.cast_unsigned())).unwrap());
850 }
851
852 #[rstest]
853 fn test_is_within_last_24_hours_when_two_days_ago() {
854 let past_ns = (Utc::now() - TimeDelta::try_days(2).unwrap())
855 .timestamp_nanos_opt()
856 .unwrap();
857 assert!(!is_within_last_24_hours(UnixNanos::from(past_ns.cast_unsigned())).unwrap());
858 }
859
860 #[rstest]
861 fn test_is_within_last_24_hours_when_future() {
862 let future_ns = (Utc::now() + TimeDelta::try_hours(1).unwrap())
864 .timestamp_nanos_opt()
865 .unwrap();
866 assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
867
868 let future_ns = (Utc::now() + TimeDelta::try_days(1).unwrap())
870 .timestamp_nanos_opt()
871 .unwrap();
872 assert!(!is_within_last_24_hours(UnixNanos::from(future_ns.cast_unsigned())).unwrap());
873 }
874
875 #[rstest]
876 #[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(
881 #[case] input: DateTime<Utc>,
882 #[case] months: u32,
883 #[case] expected: DateTime<Utc>,
884 ) {
885 let result = subtract_n_months(input, months).unwrap();
886 assert_eq!(result, expected);
887 }
888
889 #[rstest]
890 #[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(
895 #[case] input: DateTime<Utc>,
896 #[case] months: u32,
897 #[case] expected: DateTime<Utc>,
898 ) {
899 let result = add_n_months(input, months).unwrap();
900 assert_eq!(result, expected);
901 }
902
903 #[rstest]
904 fn test_add_n_years_overflow() {
905 let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
906 let err = add_n_years(datetime, u32::MAX).unwrap_err();
907 assert!(err.to_string().contains("month count overflow"));
908 }
909
910 #[rstest]
911 fn test_subtract_n_years_overflow() {
912 let datetime = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
913 let err = subtract_n_years(datetime, u32::MAX).unwrap_err();
914 assert!(err.to_string().contains("month count overflow"));
915 }
916
917 #[rstest]
918 fn test_add_n_years_nanos_overflow() {
919 let nanos = UnixNanos::from(0);
920 let err = add_n_years_nanos(nanos, u32::MAX).unwrap_err();
921 assert!(err.to_string().contains("month count overflow"));
922 }
923
924 #[rstest]
925 #[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) {
930 let result = last_day_of_month(year, month).unwrap();
931 assert_eq!(result, expected);
932 }
933
934 #[rstest]
935 #[case(2024, true)] #[case(1900, false)] #[case(2000, true)] #[case(2023, false)] fn test_is_leap_year(#[case] year: i32, #[case] expected: bool) {
940 let result = is_leap_year(year);
941 assert_eq!(result, expected);
942 }
943
944 #[rstest]
945 #[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) {
954 let result = iso8601_to_unix_nanos(input).unwrap();
955 assert_eq!(result.as_u64(), expected);
956 }
957
958 #[rstest]
959 #[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) {
964 let result = iso8601_to_unix_nanos(input);
965 assert!(result.is_err());
966 }
967
968 #[rstest]
969 fn test_iso8601_roundtrip() {
970 let original_nanos = UnixNanos::from(1_707_577_123_456_789_000);
971 let iso8601_string = unix_nanos_to_iso8601(original_nanos);
972 let parsed_nanos = iso8601_to_unix_nanos(&iso8601_string).unwrap();
973 assert_eq!(parsed_nanos, original_nanos);
974 }
975
976 #[rstest]
977 fn test_add_n_years_nanos_normal_case() {
978 let start = UnixNanos::from(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap());
980 let result = add_n_years_nanos(start, 1).unwrap();
981 let expected = UnixNanos::from(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap());
982 assert_eq!(result, expected);
983 }
984
985 #[rstest]
986 fn test_add_n_years_nanos_prevents_negative_timestamp() {
987 let start = UnixNanos::from(0); let result = add_n_years_nanos(start, 1);
993 assert!(result.is_ok());
994 }
995
996 #[rstest]
997 fn test_datetime_to_unix_nanos_at_epoch() {
998 let epoch = Utc.timestamp_opt(0, 0).unwrap();
1000 let result = datetime_to_unix_nanos(Some(epoch));
1001 assert_eq!(result, Some(UnixNanos::from(0)));
1002 }
1003
1004 #[rstest]
1005 fn test_datetime_to_unix_nanos_typical_datetime() {
1006 let dt = Utc
1007 .with_ymd_and_hms(2024, 1, 15, 13, 30, 45)
1008 .unwrap()
1009 .with_nanosecond(123_456_789)
1010 .unwrap();
1011 let result = datetime_to_unix_nanos(Some(dt));
1012
1013 assert!(result.is_some());
1015 assert_eq!(result.unwrap().as_u64(), 1_705_325_445_123_456_789);
1016 }
1017
1018 #[rstest]
1019 fn test_datetime_to_unix_nanos_before_epoch() {
1020 let before_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 59).unwrap();
1023 let result = datetime_to_unix_nanos(Some(before_epoch));
1024 assert_eq!(result, None);
1025 }
1026
1027 #[rstest]
1028 fn test_datetime_to_unix_nanos_one_second_after_epoch() {
1029 let dt = Utc.timestamp_opt(1, 0).unwrap();
1031 let result = datetime_to_unix_nanos(Some(dt));
1032 assert_eq!(result, Some(UnixNanos::from(1_000_000_000)));
1033 }
1034
1035 #[rstest]
1036 fn test_datetime_to_unix_nanos_with_subsecond_precision() {
1037 let dt = Utc.timestamp_opt(0, 1_000).unwrap(); let result = datetime_to_unix_nanos(Some(dt));
1040 assert_eq!(result, Some(UnixNanos::from(1_000)));
1041 }
1042
1043 #[rstest]
1044 fn test_nanos_helpers_return_err_for_values_above_i64_max() {
1045 let large = UnixNanos::from(u64::MAX);
1046 assert!(subtract_n_months_nanos(large, 1).is_err());
1047 assert!(add_n_months_nanos(large, 1).is_err());
1048 assert!(add_n_years_nanos(large, 1).is_err());
1049 assert!(subtract_n_years_nanos(large, 1).is_err());
1050 }
1051}