1use std::time::{Duration, SystemTime};
8
9use jiff::SignedDuration;
10use jiff::civil::{DateTime, DateTimeRound, date};
11use jiff::tz::{self, TimeZone};
12use jiff::{RoundMode, Timestamp, ToSpan, Unit, Zoned, ZonedRound};
13
14use crate::runtime::Never;
15use crate::{Effect, fail, succeed};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum TimeUnit {
22 Year,
24 Month,
26 Week,
28 Day,
30 Hour,
32 Minute,
34 Second,
36}
37
38impl TimeUnit {
39 const fn to_jiff_unit(self) -> Option<Unit> {
40 match self {
41 Self::Second => Some(Unit::Second),
42 Self::Minute => Some(Unit::Minute),
43 Self::Hour => Some(Unit::Hour),
44 Self::Day => Some(Unit::Day),
45 Self::Week | Self::Month | Self::Year => None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct UtcDateTime(
55 pub jiff::Timestamp,
57);
58
59impl UtcDateTime {
60 pub fn now() -> Effect<Self, Never, ()> {
62 succeed(Self(Timestamp::now()))
63 }
64
65 #[inline]
67 pub fn from_epoch_millis(ms: i64) -> Option<Self> {
68 Timestamp::from_millisecond(ms).ok().map(Self)
69 }
70
71 #[inline]
73 pub fn unsafe_make(ms: i64) -> Self {
74 Self(
75 Timestamp::from_millisecond(ms)
76 .unwrap_or_else(|e| panic!("UtcDateTime::unsafe_make: invalid millis {ms}: {e}")),
77 )
78 }
79
80 #[inline]
82 pub fn from_std(t: SystemTime) -> Option<Self> {
83 Timestamp::try_from(t).ok().map(Self)
84 }
85
86 #[inline]
88 pub fn inner(self) -> Timestamp {
89 self.0
90 }
91
92 #[inline]
94 pub fn to_epoch_millis(&self) -> i64 {
95 self.0.as_millisecond()
96 }
97
98 #[inline]
100 pub fn to_zoned(self, zone: TimeZone) -> ZonedDateTime {
101 ZonedDateTime(self.0.to_zoned(zone))
102 }
103
104 #[inline]
106 pub fn year(&self) -> i16 {
107 utc_civil(self.0).year()
108 }
109
110 #[inline]
112 pub fn month(&self) -> i8 {
113 utc_civil(self.0).month()
114 }
115
116 #[inline]
118 pub fn day(&self) -> i8 {
119 utc_civil(self.0).day()
120 }
121
122 #[inline]
124 pub fn hour(&self) -> i8 {
125 utc_civil(self.0).hour()
126 }
127
128 #[inline]
130 pub fn minute(&self) -> i8 {
131 utc_civil(self.0).minute()
132 }
133
134 #[inline]
136 pub fn second(&self) -> i8 {
137 utc_civil(self.0).second()
138 }
139
140 #[inline]
142 pub fn add_duration(&self, d: Duration) -> Self {
143 Self(
144 self
145 .0
146 .checked_add(d)
147 .unwrap_or_else(|e| panic!("UtcDateTime::add_duration overflow: {e}")),
148 )
149 }
150
151 #[inline]
153 pub fn subtract_duration(&self, d: Duration) -> Self {
154 Self(
155 self
156 .0
157 .checked_sub(d)
158 .unwrap_or_else(|e| panic!("UtcDateTime::subtract_duration overflow: {e}")),
159 )
160 }
161
162 pub fn start_of(&self, unit: TimeUnit) -> Self {
164 Self(start_timestamp_utc(self.0, unit).expect("start_of: jiff round error"))
165 }
166
167 pub fn end_of(&self, unit: TimeUnit) -> Self {
169 let start = self.start_of(unit);
170 let next = advance_start_utc(start.0, unit).expect("end_of: advance");
171 Self(
172 next
173 .checked_sub(1.nanosecond())
174 .expect("end_of: subtract 1ns"),
175 )
176 }
177
178 pub fn nearest(&self, unit: TimeUnit) -> Self {
180 Self(nearest_timestamp_utc(self.0, unit).expect("nearest: jiff error"))
181 }
182
183 #[inline]
185 pub fn distance_millis(&self, other: &Self) -> i64 {
186 other.to_epoch_millis() - self.to_epoch_millis()
187 }
188
189 #[inline]
191 pub fn distance_duration(&self, other: &Self) -> Duration {
192 let sd = (other.0.as_duration() - self.0.as_duration()).abs();
193 Duration::try_from(sd).expect("absolute span fits std::time::Duration")
194 }
195
196 #[inline]
198 pub fn format_iso(&self) -> String {
199 self.0.to_string()
200 }
201
202 #[inline]
204 pub fn format(&self, fmt: &str) -> String {
205 self.0.strftime(fmt).to_string()
206 }
207
208 #[inline]
210 pub fn less_than(&self, other: &Self) -> bool {
211 self.0 < other.0
212 }
213
214 #[inline]
216 pub fn greater_than(&self, other: &Self) -> bool {
217 self.0 > other.0
218 }
219
220 #[inline]
222 pub fn between(&self, min: &Self, max: &Self) -> bool {
223 self.0 >= min.0 && self.0 <= max.0
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Hash)]
231pub struct ZonedDateTime(
232 pub Zoned,
234);
235
236impl ZonedDateTime {
237 pub fn now() -> Effect<Self, Never, ()> {
239 succeed(Self(Zoned::now()))
240 }
241
242 #[inline]
244 pub fn from_epoch_millis(ms: i64, zone: TimeZone) -> Option<Self> {
245 let ts = Timestamp::from_millisecond(ms).ok()?;
246 Some(Self(ts.to_zoned(zone)))
247 }
248
249 #[inline]
251 pub fn unsafe_make(ms: i64, zone: TimeZone) -> Self {
252 Self(UtcDateTime::unsafe_make(ms).0.to_zoned(zone))
253 }
254
255 #[inline]
257 pub fn from_std(t: SystemTime, zone: TimeZone) -> Option<Self> {
258 let ts = Timestamp::try_from(t).ok()?;
259 Some(Self(ts.to_zoned(zone)))
260 }
261
262 #[inline]
264 pub fn inner(&self) -> &Zoned {
265 &self.0
266 }
267
268 #[inline]
270 pub fn into_inner(self) -> Zoned {
271 self.0
272 }
273
274 #[inline]
276 pub fn to_epoch_millis(&self) -> i64 {
277 self.0.timestamp().as_millisecond()
278 }
279
280 #[inline]
282 pub fn year(&self) -> i16 {
283 self.0.year()
284 }
285
286 #[inline]
288 pub fn month(&self) -> i8 {
289 self.0.month()
290 }
291
292 #[inline]
294 pub fn day(&self) -> i8 {
295 self.0.day()
296 }
297
298 #[inline]
300 pub fn hour(&self) -> i8 {
301 self.0.hour()
302 }
303
304 #[inline]
306 pub fn minute(&self) -> i8 {
307 self.0.minute()
308 }
309
310 #[inline]
312 pub fn second(&self) -> i8 {
313 self.0.second()
314 }
315
316 #[inline]
318 pub fn time_zone(&self) -> TimeZone {
319 self.0.time_zone().clone()
320 }
321
322 #[inline]
324 pub fn add_duration(&self, d: Duration) -> Self {
325 Self(
326 self
327 .0
328 .clone()
329 .checked_add(d)
330 .unwrap_or_else(|e| panic!("ZonedDateTime::add_duration overflow: {e}")),
331 )
332 }
333
334 #[inline]
336 pub fn subtract_duration(&self, d: Duration) -> Self {
337 Self(
338 self
339 .0
340 .clone()
341 .checked_sub(d)
342 .unwrap_or_else(|e| panic!("ZonedDateTime::subtract_duration overflow: {e}")),
343 )
344 }
345
346 pub fn start_of(&self, unit: TimeUnit) -> Self {
348 Self(start_zoned(&self.0, unit).expect("ZonedDateTime::start_of"))
349 }
350
351 pub fn end_of(&self, unit: TimeUnit) -> Self {
353 let start = self.start_of(unit);
354 let tz = start.time_zone();
355 let next = advance_start_zoned(&start.0, unit).expect("ZonedDateTime::end_of advance");
356 let ts = next
357 .timestamp()
358 .checked_sub(1.nanosecond())
359 .expect("ZonedDateTime::end_of subtract");
360 Self(ts.to_zoned(tz))
361 }
362
363 pub fn nearest(&self, unit: TimeUnit) -> Self {
365 Self(nearest_zoned(&self.0, unit).expect("ZonedDateTime::nearest"))
366 }
367
368 #[inline]
370 pub fn distance_millis(&self, other: &Self) -> i64 {
371 other.to_epoch_millis() - self.to_epoch_millis()
372 }
373
374 #[inline]
376 pub fn distance_duration(&self, other: &Self) -> Duration {
377 let sd = (other.0.timestamp().as_duration() - self.0.timestamp().as_duration()).abs();
378 Duration::try_from(sd).expect("absolute span fits std::time::Duration")
379 }
380
381 #[inline]
383 pub fn format_iso(&self) -> String {
384 self.0.to_string()
385 }
386
387 #[inline]
389 pub fn format(&self, fmt: &str) -> String {
390 self.0.strftime(fmt).to_string()
391 }
392
393 #[inline]
395 pub fn less_than(&self, other: &Self) -> bool {
396 self.0.timestamp() < other.0.timestamp()
397 }
398
399 #[inline]
401 pub fn greater_than(&self, other: &Self) -> bool {
402 self.0.timestamp() > other.0.timestamp()
403 }
404
405 #[inline]
407 pub fn between(&self, min: &Self, max: &Self) -> bool {
408 let t = self.0.timestamp();
409 t >= min.0.timestamp() && t <= max.0.timestamp()
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Hash)]
417pub enum AnyDateTime {
418 Utc(UtcDateTime),
420 Zoned(ZonedDateTime),
422}
423
424pub mod timezone {
428 use super::*;
429
430 #[derive(Debug, Clone, PartialEq, Eq)]
432 pub struct TimeZoneError {
433 pub id: String,
435 }
436
437 impl std::fmt::Display for TimeZoneError {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 write!(f, "unknown or invalid time zone {:?}", self.id)
440 }
441 }
442
443 impl std::error::Error for TimeZoneError {}
444
445 #[inline]
447 pub fn utc() -> TimeZone {
448 TimeZone::UTC
449 }
450
451 #[inline]
453 pub fn offset(minutes: i32) -> TimeZone {
454 let seconds = minutes.checked_mul(60).expect("offset minutes overflow");
455 tz::Offset::from_seconds(seconds)
456 .expect("offset seconds out of range")
457 .to_time_zone()
458 }
459
460 pub fn named(iana_id: &str) -> Effect<TimeZone, TimeZoneError, ()> {
462 match TimeZone::get(iana_id) {
463 Ok(tz) => succeed(tz),
464 Err(_) => fail(TimeZoneError {
465 id: iana_id.to_string(),
466 }),
467 }
468 }
469
470 pub fn from_str(s: &str) -> Option<TimeZone> {
472 let s = s.trim();
473 if s.is_empty() {
474 return None;
475 }
476 if let Ok(tz) = TimeZone::get(s) {
477 return Some(tz);
478 }
479 parse_fixed_offset_time_zone(s)
480 }
481}
482
483#[inline]
486fn utc_civil(ts: Timestamp) -> DateTime {
487 ts.to_zoned(TimeZone::UTC).datetime()
488}
489
490fn civil_to_utc(ts: DateTime) -> Result<Timestamp, jiff::Error> {
491 Ok(ts.to_zoned(TimeZone::UTC)?.timestamp())
492}
493
494fn start_timestamp_utc(ts: Timestamp, unit: TimeUnit) -> Result<Timestamp, jiff::Error> {
495 match unit {
496 TimeUnit::Second | TimeUnit::Minute | TimeUnit::Hour | TimeUnit::Day => {
497 let dt = utc_civil(ts);
498 let u = unit.to_jiff_unit().expect("mapped unit");
499 let rounded = dt.round(DateTimeRound::new().smallest(u).mode(RoundMode::Trunc))?;
500 civil_to_utc(rounded)
501 }
502 TimeUnit::Week => {
503 let dt = utc_civil(ts);
504 let d = dt.date();
505 let off = i64::from(d.weekday().to_monday_zero_offset());
506 let week_start = d.checked_sub(off.days())?.at(0, 0, 0, 0);
507 civil_to_utc(week_start)
508 }
509 TimeUnit::Month => {
510 let dt = utc_civil(ts);
511 let d = date(dt.year(), dt.month(), 1);
512 civil_to_utc(d.at(0, 0, 0, 0))
513 }
514 TimeUnit::Year => {
515 let dt = utc_civil(ts);
516 let d = date(dt.year(), 1, 1);
517 civil_to_utc(d.at(0, 0, 0, 0))
518 }
519 }
520}
521
522fn advance_start_utc(ts: Timestamp, unit: TimeUnit) -> Result<Timestamp, jiff::Error> {
523 let dt = utc_civil(ts);
524 let span = unit_span(unit)?;
525 let next = dt.checked_add(span)?;
526 civil_to_utc(next)
527}
528
529fn unit_span(unit: TimeUnit) -> Result<jiff::Span, jiff::Error> {
530 Ok(match unit {
531 TimeUnit::Second => 1.second(),
532 TimeUnit::Minute => 1.minute(),
533 TimeUnit::Hour => 1.hour(),
534 TimeUnit::Day => 1.day(),
535 TimeUnit::Week => 1.week(),
536 TimeUnit::Month => 1.month(),
537 TimeUnit::Year => 1.year(),
538 })
539}
540
541fn nearest_timestamp_utc(ts: Timestamp, unit: TimeUnit) -> Result<Timestamp, jiff::Error> {
542 if let Some(u) = unit.to_jiff_unit() {
543 let dt = utc_civil(ts);
544 return civil_to_utc(dt.round(u)?);
545 }
546 let start = start_timestamp_utc(ts, unit)?;
547 let next = advance_start_utc(start, unit)?;
548 let span = (next.as_duration() - start.as_duration()).abs();
549 let half_nanos = span.as_nanos() / 2;
550 let half = SignedDuration::from_nanos_i128(half_nanos);
551 let mid_ts = start.checked_add(half).map_err(|_| {
552 jiff::Error::from_args(format_args!("nearest_timestamp_utc: midpoint overflow"))
553 })?;
554 if ts < mid_ts { Ok(start) } else { Ok(next) }
555}
556
557fn start_zoned(z: &Zoned, unit: TimeUnit) -> Result<Zoned, jiff::Error> {
560 let tz = z.time_zone().clone();
561 match unit {
562 TimeUnit::Second | TimeUnit::Minute | TimeUnit::Hour | TimeUnit::Day => {
563 let u = unit.to_jiff_unit().expect("mapped");
564 z.round(ZonedRound::new().smallest(u).mode(RoundMode::Trunc))
565 }
566 TimeUnit::Week => {
567 let dt = z.datetime();
568 let d = dt.date();
569 let off = i64::from(d.weekday().to_monday_zero_offset());
570 let week_start = d.checked_sub(off.days())?.at(0, 0, 0, 0);
571 week_start.to_zoned(tz)
572 }
573 TimeUnit::Month => {
574 let dt = z.datetime();
575 let d = date(dt.year(), dt.month(), 1);
576 d.at(0, 0, 0, 0).to_zoned(tz)
577 }
578 TimeUnit::Year => {
579 let dt = z.datetime();
580 let d = date(dt.year(), 1, 1);
581 d.at(0, 0, 0, 0).to_zoned(tz)
582 }
583 }
584}
585
586fn advance_start_zoned(z: &Zoned, unit: TimeUnit) -> Result<Zoned, jiff::Error> {
587 let tz = z.time_zone().clone();
588 let dt = z.datetime();
589 let span = unit_span(unit)?;
590 dt.checked_add(span)?.to_zoned(tz)
591}
592
593fn nearest_zoned(z: &Zoned, unit: TimeUnit) -> Result<Zoned, jiff::Error> {
594 if let Some(u) = unit.to_jiff_unit() {
595 return z.round(u);
596 }
597 let start = start_zoned(z, unit)?;
598 let next = advance_start_zoned(&start, unit)?;
599 let span = (next.timestamp().as_duration() - start.timestamp().as_duration()).abs();
600 let half_nanos = span.as_nanos() / 2;
601 let half = SignedDuration::from_nanos_i128(half_nanos);
602 let mid_ts = start
603 .timestamp()
604 .checked_add(half)
605 .map_err(|_| jiff::Error::from_args(format_args!("nearest_zoned: midpoint overflow")))?;
606 if z.timestamp() < mid_ts {
607 Ok(start)
608 } else {
609 Ok(next)
610 }
611}
612
613fn parse_fixed_offset_time_zone(s: &str) -> Option<TimeZone> {
614 let bytes = s.as_bytes();
615 let (sign, rest) = match bytes.first()? {
616 b'+' => (1i64, &s[1..]),
617 b'-' => (-1i64, &s[1..]),
618 _ => return None,
619 };
620 let rest = rest.trim();
621 if rest.is_empty() {
622 return None;
623 }
624 let mut parts = rest.split(':');
625 let h: i64 = parts.next()?.parse().ok()?;
626 let m: i64 = parts.next().map_or(0, |x| x.parse().unwrap_or(0));
627 let sec: i64 = parts.next().map_or(0, |x| x.parse().unwrap_or(0));
628 if parts.next().is_some() {
629 return None;
630 }
631 let total = sign.checked_mul(
632 h.checked_mul(3600)?
633 .checked_add(m.checked_mul(60)?)?
634 .checked_add(sec)?,
635 )?;
636 let seconds = i32::try_from(total).ok()?;
637 Some(tz::Offset::from_seconds(seconds).ok()?.to_time_zone())
638}
639
640#[cfg(test)]
641mod tests {
642 use super::timezone;
643 use super::*;
644 use crate::failure::exit::Exit;
645 use crate::testing::test_runtime::run_test;
646
647 #[test]
648 fn now_returns_utc_datetime() {
649 let Exit::Success(utc) = run_test(UtcDateTime::now(), ()) else {
650 panic!("expected success");
651 };
652 assert!(utc.to_epoch_millis() > 0);
653 }
654
655 #[test]
656 fn from_epoch_millis_roundtrips() {
657 let ms = 1_700_000_000_123i64;
658 let u = UtcDateTime::from_epoch_millis(ms).expect("in range");
659 assert_eq!(u.to_epoch_millis(), ms);
660 }
661
662 #[test]
663 fn format_iso_produces_valid_rfc3339() {
664 let u = UtcDateTime::unsafe_make(0);
665 let s = u.format_iso();
666 let parsed: Timestamp = s.parse().expect("parse RFC3339");
667 assert_eq!(parsed, u.0);
668 }
669
670 #[test]
671 fn start_of_day_zeroes_time_components() {
672 let u = UtcDateTime::unsafe_make(1_700_000_000_123);
673 let sod = u.start_of(TimeUnit::Day);
674 assert_eq!(sod.hour(), 0);
675 assert_eq!(sod.minute(), 0);
676 assert_eq!(sod.second(), 0);
677 }
678
679 #[test]
680 fn add_duration_crosses_day_boundary() {
681 let u = UtcDateTime::unsafe_make(1_700_000_000_000);
682 let day = u.start_of(TimeUnit::Day);
683 let next = day.add_duration(Duration::from_secs(86_400));
684 assert!(next.day() != day.day() || next.month() != day.month() || next.year() != day.year());
685 }
686
687 #[test]
688 fn named_timezone_fails_on_invalid_id() {
689 let exit = run_test(timezone::named("Not/A/Valid/Zone"), ());
690 assert!(
691 matches!(exit, Exit::Failure(_)),
692 "expected failure, got {exit:?}"
693 );
694 }
695
696 #[rstest::rstest]
697 #[case(TimeUnit::Second)]
698 #[case(TimeUnit::Minute)]
699 #[case(TimeUnit::Hour)]
700 #[case(TimeUnit::Day)]
701 fn utc_start_nearest_round_trip(#[case] unit: TimeUnit) {
702 let u = UtcDateTime::unsafe_make(1_720_000_000_000);
703 let s = u.start_of(unit);
704 let n = u.nearest(unit);
705 assert!(s.less_than(&u) || s.0 == u.0);
706 let _ = n.to_epoch_millis();
707 }
708
709 #[rstest::rstest]
710 #[case(TimeUnit::Week)]
711 #[case(TimeUnit::Month)]
712 #[case(TimeUnit::Year)]
713 fn utc_week_month_year_start_end_nearest(#[case] unit: TimeUnit) {
714 let u = UtcDateTime::unsafe_make(1_720_000_000_000);
715 let _ = u.start_of(unit);
716 let _ = u.end_of(unit);
717 let _ = u.nearest(unit);
718 }
719
720 #[test]
721 fn utc_from_std_unix_epoch() {
722 let u = UtcDateTime::from_std(std::time::UNIX_EPOCH).expect("epoch");
723 assert_eq!(u.to_epoch_millis(), 0);
724 }
725
726 #[test]
727 fn utc_civil_accessors_and_compare() {
728 let a = UtcDateTime::unsafe_make(1_700_000_000_000);
729 let b = UtcDateTime::unsafe_make(1_700_000_001_000);
730 assert!(a.year() >= 2023);
731 assert!((1..=12).contains(&a.month()));
732 assert!((1..=31).contains(&a.day()));
733 assert!(a.less_than(&b));
734 assert!(b.greater_than(&a));
735 assert!(b.between(&a, &b));
736 assert_eq!(a.distance_millis(&b), 1000);
737 assert!(a.distance_duration(&b) <= std::time::Duration::from_secs(2));
738 let _ = a.format("%Y");
739 let _ = a.format_iso();
740 }
741
742 #[test]
743 fn zoned_now_and_helpers() {
744 let Exit::Success(z) = run_test(ZonedDateTime::now(), ()) else {
745 panic!("expected success");
746 };
747 let _ = z.year();
748 let _ = z.format_iso();
749 let _ = z.format("%H");
750 let utc = UtcDateTime::unsafe_make(1_720_000_000_000);
751 let london = timezone::named("Europe/London");
752 let Exit::Success(tz) = run_test(london, ()) else {
753 panic!("zone");
754 };
755 let z2 = ZonedDateTime::from_epoch_millis(1_720_000_000_000, tz).expect("zoned");
756 assert_eq!(z2.to_epoch_millis(), 1_720_000_000_000);
757 let z3 = utc.to_zoned(z2.time_zone());
758 assert_eq!(z3.to_epoch_millis(), z2.to_epoch_millis());
759 let _ = z2.start_of(TimeUnit::Day);
760 let _ = z2.end_of(TimeUnit::Hour);
761 let _ = z2.nearest(TimeUnit::Week);
762 }
763
764 #[test]
765 fn timezone_helpers_parse_offsets() {
766 assert!(timezone::utc() == TimeZone::UTC);
767 let o = timezone::offset(90);
768 let z = ZonedDateTime::unsafe_make(0, o);
769 assert_eq!(z.to_epoch_millis(), 0);
770 assert!(timezone::from_str("Europe/London").is_some());
771 assert!(timezone::from_str("+01:00").is_some());
772 assert!(timezone::from_str("-05:30").is_some());
773 assert!(timezone::from_str(" ").is_none());
774 assert!(timezone::from_str("not-a-zone-or-offset").is_none());
775 }
776
777 #[test]
780 fn utc_inner_returns_underlying_timestamp() {
781 let ms = 1_700_000_000_000i64;
782 let u = UtcDateTime::unsafe_make(ms);
783 let ts = u.inner();
784 assert_eq!(ts.as_millisecond(), ms);
785 }
786
787 #[test]
788 fn utc_subtract_duration_basic() {
789 let u = UtcDateTime::unsafe_make(1_700_000_000_000);
790 let earlier = u.subtract_duration(Duration::from_secs(3600));
791 assert_eq!(u.to_epoch_millis() - earlier.to_epoch_millis(), 3_600_000);
792 }
793
794 #[test]
795 fn zoned_from_std_unix_epoch() {
796 let z = ZonedDateTime::from_std(std::time::UNIX_EPOCH, TimeZone::UTC).expect("epoch");
797 assert_eq!(z.to_epoch_millis(), 0);
798 }
799
800 #[test]
801 fn zoned_from_std_returns_none_on_out_of_range() {
802 let huge = Duration::from_secs(u64::MAX / 2);
805 let Some(far_future) = std::time::UNIX_EPOCH.checked_add(huge) else {
806 return;
807 };
808 let _ = ZonedDateTime::from_std(far_future, TimeZone::UTC);
810 }
811
812 #[test]
813 fn zoned_inner_borrows_underlying_zoned() {
814 let z = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
815 let inner: &Zoned = z.inner();
816 assert_eq!(inner.timestamp().as_millisecond(), 1_700_000_000_000);
817 }
818
819 #[test]
820 fn zoned_into_inner_consumes_and_returns_zoned() {
821 let z = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
822 let ms = z.to_epoch_millis();
823 let inner: Zoned = z.into_inner();
824 assert_eq!(inner.timestamp().as_millisecond(), ms);
825 }
826
827 #[test]
828 fn zoned_civil_accessors_month_day_hour_minute_second() {
829 let ms = 1_720_096_496_000i64;
831 let z = ZonedDateTime::unsafe_make(ms, TimeZone::UTC);
832 assert_eq!(z.year(), 2024);
833 assert_eq!(z.month(), 7);
834 assert_eq!(z.day(), 4);
835 assert_eq!(z.hour(), 12);
836 assert_eq!(z.minute(), 34);
837 assert_eq!(z.second(), 56);
838 }
839
840 #[test]
841 fn zoned_add_and_subtract_duration() {
842 let z = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
843 let later = z.add_duration(Duration::from_secs(3600));
844 assert_eq!(later.to_epoch_millis() - z.to_epoch_millis(), 3_600_000);
845 let back = later.subtract_duration(Duration::from_secs(3600));
846 assert_eq!(back.to_epoch_millis(), z.to_epoch_millis());
847 }
848
849 #[test]
850 fn zoned_distance_millis_signed() {
851 let a = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
852 let b = ZonedDateTime::unsafe_make(1_700_000_005_000, TimeZone::UTC);
853 assert_eq!(a.distance_millis(&b), 5000);
854 assert_eq!(b.distance_millis(&a), -5000);
855 }
856
857 #[test]
860 fn any_datetime_utc_variant() {
861 let u = UtcDateTime::unsafe_make(1_700_000_000_000);
862 let any = AnyDateTime::Utc(u.clone());
863 match any {
864 AnyDateTime::Utc(inner) => assert_eq!(inner.to_epoch_millis(), u.to_epoch_millis()),
865 AnyDateTime::Zoned(_) => panic!("expected Utc"),
866 }
867 }
868
869 #[test]
870 fn any_datetime_zoned_variant() {
871 let z = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
872 let any = AnyDateTime::Zoned(z.clone());
873 match any {
874 AnyDateTime::Zoned(inner) => assert_eq!(inner.to_epoch_millis(), z.to_epoch_millis()),
875 AnyDateTime::Utc(_) => panic!("expected Zoned"),
876 }
877 }
878
879 #[test]
882 fn zoned_less_than_greater_than_between() {
883 let a = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
884 let b = ZonedDateTime::unsafe_make(1_700_000_001_000, TimeZone::UTC);
885 assert!(a.less_than(&b));
886 assert!(!b.less_than(&a));
887 assert!(b.greater_than(&a));
888 assert!(!a.greater_than(&b));
889 assert!(b.between(&a, &b));
890 assert!(!a.between(&b, &b));
891 }
892
893 #[test]
896 fn zoned_distance_duration_absolute() {
897 let a = ZonedDateTime::unsafe_make(1_700_000_000_000, TimeZone::UTC);
898 let b = ZonedDateTime::unsafe_make(1_700_000_005_000, TimeZone::UTC);
899 let d = a.distance_duration(&b);
900 assert_eq!(d, std::time::Duration::from_secs(5));
901 let d2 = b.distance_duration(&a);
903 assert_eq!(d2, std::time::Duration::from_secs(5));
904 }
905
906 #[test]
909 fn timezone_error_display_and_error_trait() {
910 let err = timezone::TimeZoneError {
911 id: "Bad/Zone".into(),
912 };
913 let s = format!("{err}");
914 assert!(s.contains("Bad/Zone"), "display should mention the id: {s}");
915 use std::error::Error;
916 assert!(err.source().is_none());
917 }
918
919 #[test]
922 fn utc_from_epoch_millis_out_of_range_returns_none() {
923 assert!(UtcDateTime::from_epoch_millis(i64::MAX).is_none());
925 assert!(UtcDateTime::from_epoch_millis(i64::MIN).is_none());
926 }
927
928 #[test]
929 fn zoned_from_epoch_millis_out_of_range_returns_none() {
930 assert!(ZonedDateTime::from_epoch_millis(i64::MAX, TimeZone::UTC).is_none());
931 assert!(ZonedDateTime::from_epoch_millis(i64::MIN, TimeZone::UTC).is_none());
932 }
933
934 #[test]
937 fn timezone_from_str_fixed_offset_edge_cases() {
938 assert!(timezone::from_str("+00:00").is_some());
940 assert!(timezone::from_str("-00:00").is_some());
941 assert!(timezone::from_str("+05").is_some());
943 assert!(timezone::from_str("-09").is_some());
944 assert!(timezone::from_str("+01:30:00").is_some());
946 assert!(timezone::from_str("+").is_none());
948 assert!(timezone::from_str("-").is_none());
949 assert!(timezone::from_str("+01:00:00:00").is_none());
951 assert!(timezone::from_str("+xx:00").is_none());
953 }
954
955 #[test]
958 fn named_timezone_succeeds_on_valid_id() {
959 let exit = run_test(timezone::named("UTC"), ());
960 assert!(
961 matches!(exit, Exit::Success(_)),
962 "expected success for UTC: {exit:?}"
963 );
964 }
965
966 #[test]
969 fn zoned_start_end_nearest_all_units() {
970 use crate::scheduling::datetime::TimeUnit::*;
971 let z = ZonedDateTime::unsafe_make(1_720_000_000_000, TimeZone::UTC);
972 for unit in [Second, Minute, Hour, Day, Week, Month, Year] {
973 let _ = z.start_of(unit);
974 let _ = z.end_of(unit);
975 let _ = z.nearest(unit);
976 }
977 }
978
979 #[test]
982 fn utc_end_of_basic_units() {
983 use crate::scheduling::datetime::TimeUnit::*;
984 let u = UtcDateTime::unsafe_make(1_720_000_000_000);
985 for unit in [Second, Minute, Hour, Day] {
986 let e = u.end_of(unit);
987 assert!(e.greater_than(&u.start_of(unit)));
988 }
989 }
990}