Skip to main content

id_effect/scheduling/
datetime.rs

1//! Wall-clock and instant types backed by [`jiff`] — mirrors Effect.ts `DateTime` / time zones.
2//!
3//! UTC instants use [`UtcDateTime`] ([`jiff::Timestamp`]). Zone-aware values use [`ZonedDateTime`]
4//! ([`jiff::Zoned`]). Fallible IANA lookup is expressed as [`Effect`] via
5//! [`timezone::named`].
6
7use 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// ── TimeUnit ─────────────────────────────────────────────────────────────────
18
19/// Calendar / clock unit for boundary and rounding helpers.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum TimeUnit {
22  /// Calendar year boundary in UTC or zoned civil time.
23  Year,
24  /// Month boundary.
25  Month,
26  /// Week boundary (Monday-based).
27  Week,
28  /// Day boundary (midnight in the relevant zone / UTC).
29  Day,
30  /// Hour boundary.
31  Hour,
32  /// Minute boundary.
33  Minute,
34  /// Second boundary.
35  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// ── UtcDateTime ──────────────────────────────────────────────────────────────
51
52/// An absolute instant in UTC (nanosecond-precision Unix timeline).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct UtcDateTime(
55  /// Underlying nanosecond-precision UTC instant ([`jiff::Timestamp`]).
56  pub jiff::Timestamp,
57);
58
59impl UtcDateTime {
60  /// Current system time as UTC ([`Timestamp::now`]).
61  pub fn now() -> Effect<Self, Never, ()> {
62    succeed(Self(Timestamp::now()))
63  }
64
65  /// Construct from Unix milliseconds when in range.
66  #[inline]
67  pub fn from_epoch_millis(ms: i64) -> Option<Self> {
68    Timestamp::from_millisecond(ms).ok().map(Self)
69  }
70
71  /// Construct from Unix milliseconds; panics when out of range (caller assertion, not `unsafe`).
72  #[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  /// Convert from [`SystemTime`] (fallible on out-of-range values).
81  #[inline]
82  pub fn from_std(t: SystemTime) -> Option<Self> {
83    Timestamp::try_from(t).ok().map(Self)
84  }
85
86  /// Unwrap to the raw [`Timestamp`].
87  #[inline]
88  pub fn inner(self) -> Timestamp {
89    self.0
90  }
91
92  /// Unix time in whole milliseconds.
93  #[inline]
94  pub fn to_epoch_millis(&self) -> i64 {
95    self.0.as_millisecond()
96  }
97
98  /// View / project this instant into a specific IANA or fixed-offset zone.
99  #[inline]
100  pub fn to_zoned(self, zone: TimeZone) -> ZonedDateTime {
101    ZonedDateTime(self.0.to_zoned(zone))
102  }
103
104  /// Civil year in UTC.
105  #[inline]
106  pub fn year(&self) -> i16 {
107    utc_civil(self.0).year()
108  }
109
110  /// Civil month in UTC (1–12).
111  #[inline]
112  pub fn month(&self) -> i8 {
113    utc_civil(self.0).month()
114  }
115
116  /// Civil day of month in UTC.
117  #[inline]
118  pub fn day(&self) -> i8 {
119    utc_civil(self.0).day()
120  }
121
122  /// Civil hour in UTC (0–23).
123  #[inline]
124  pub fn hour(&self) -> i8 {
125    utc_civil(self.0).hour()
126  }
127
128  /// Civil minute in UTC.
129  #[inline]
130  pub fn minute(&self) -> i8 {
131    utc_civil(self.0).minute()
132  }
133
134  /// Civil second in UTC.
135  #[inline]
136  pub fn second(&self) -> i8 {
137    utc_civil(self.0).second()
138  }
139
140  /// Add `d` to this instant; panics on overflow.
141  #[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  /// Subtract `d` from this instant; panics on underflow.
152  #[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  /// First instant of the unit (UTC civil calendar).
163  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  /// Last representable instant inside the unit (UTC civil calendar), inclusive.
168  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  /// Round to the nearest boundary of `unit` (UTC civil calendar).
179  pub fn nearest(&self, unit: TimeUnit) -> Self {
180    Self(nearest_timestamp_utc(self.0, unit).expect("nearest: jiff error"))
181  }
182
183  /// Signed difference `other − self` in whole milliseconds.
184  #[inline]
185  pub fn distance_millis(&self, other: &Self) -> i64 {
186    other.to_epoch_millis() - self.to_epoch_millis()
187  }
188
189  /// Absolute span between `self` and `other` as [`Duration`].
190  #[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  /// RFC 3339 / ISO-8601 instant string with `Z` suffix.
197  #[inline]
198  pub fn format_iso(&self) -> String {
199    self.0.to_string()
200  }
201
202  /// `strftime`-style formatting in UTC.
203  #[inline]
204  pub fn format(&self, fmt: &str) -> String {
205    self.0.strftime(fmt).to_string()
206  }
207
208  /// Strictly before `other` on the UTC timeline.
209  #[inline]
210  pub fn less_than(&self, other: &Self) -> bool {
211    self.0 < other.0
212  }
213
214  /// Strictly after `other` on the UTC timeline.
215  #[inline]
216  pub fn greater_than(&self, other: &Self) -> bool {
217    self.0 > other.0
218  }
219
220  /// Inclusive range check between `min` and `max` on the UTC timeline.
221  #[inline]
222  pub fn between(&self, min: &Self, max: &Self) -> bool {
223    self.0 >= min.0 && self.0 <= max.0
224  }
225}
226
227// ── ZonedDateTime ────────────────────────────────────────────────────────────
228
229/// An instant with a resolved time zone ([`jiff::Zoned`]).
230#[derive(Debug, Clone, PartialEq, Eq, Hash)]
231pub struct ZonedDateTime(
232  /// Underlying zoned instant ([`jiff::Zoned`]).
233  pub Zoned,
234);
235
236impl ZonedDateTime {
237  /// “Now” in the system default zone ([`Zoned::now`]).
238  pub fn now() -> Effect<Self, Never, ()> {
239    succeed(Self(Zoned::now()))
240  }
241
242  /// Build from Unix milliseconds in `zone` when in range.
243  #[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  /// Like [`UtcDateTime::unsafe_make`] then project into `zone`.
250  #[inline]
251  pub fn unsafe_make(ms: i64, zone: TimeZone) -> Self {
252    Self(UtcDateTime::unsafe_make(ms).0.to_zoned(zone))
253  }
254
255  /// Convert [`SystemTime`] through UTC then attach `zone`.
256  #[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  /// Borrow the inner [`Zoned`].
263  #[inline]
264  pub fn inner(&self) -> &Zoned {
265    &self.0
266  }
267
268  /// Consume and return the inner [`Zoned`].
269  #[inline]
270  pub fn into_inner(self) -> Zoned {
271    self.0
272  }
273
274  /// Unix time in whole milliseconds (instant, ignoring display offset quirks).
275  #[inline]
276  pub fn to_epoch_millis(&self) -> i64 {
277    self.0.timestamp().as_millisecond()
278  }
279
280  /// Civil year in this value’s zone.
281  #[inline]
282  pub fn year(&self) -> i16 {
283    self.0.year()
284  }
285
286  /// Civil month (1–12) in this value’s zone.
287  #[inline]
288  pub fn month(&self) -> i8 {
289    self.0.month()
290  }
291
292  /// Civil day of month in this value’s zone.
293  #[inline]
294  pub fn day(&self) -> i8 {
295    self.0.day()
296  }
297
298  /// Civil hour (0–23) in this value’s zone.
299  #[inline]
300  pub fn hour(&self) -> i8 {
301    self.0.hour()
302  }
303
304  /// Civil minute in this value’s zone.
305  #[inline]
306  pub fn minute(&self) -> i8 {
307    self.0.minute()
308  }
309
310  /// Civil second in this value’s zone.
311  #[inline]
312  pub fn second(&self) -> i8 {
313    self.0.second()
314  }
315
316  /// Resolved IANA or fixed-offset zone.
317  #[inline]
318  pub fn time_zone(&self) -> TimeZone {
319    self.0.time_zone().clone()
320  }
321
322  /// Add `d` in this zone; panics on overflow.
323  #[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  /// Subtract `d` in this zone; panics on underflow.
335  #[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  /// First instant of `unit` in this zone’s civil calendar.
347  pub fn start_of(&self, unit: TimeUnit) -> Self {
348    Self(start_zoned(&self.0, unit).expect("ZonedDateTime::start_of"))
349  }
350
351  /// Last representable instant inside `unit` in this zone, inclusive.
352  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  /// Round to the nearest boundary of `unit` in this zone’s civil calendar.
364  pub fn nearest(&self, unit: TimeUnit) -> Self {
365    Self(nearest_zoned(&self.0, unit).expect("ZonedDateTime::nearest"))
366  }
367
368  /// Signed difference of instants `other − self` in whole milliseconds.
369  #[inline]
370  pub fn distance_millis(&self, other: &Self) -> i64 {
371    other.to_epoch_millis() - self.to_epoch_millis()
372  }
373
374  /// Absolute span between the two instants as [`Duration`].
375  #[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  /// Full ISO-8601 string including offset / zone name per [`jiff`].
382  #[inline]
383  pub fn format_iso(&self) -> String {
384    self.0.to_string()
385  }
386
387  /// `strftime`-style formatting in this value’s zone.
388  #[inline]
389  pub fn format(&self, fmt: &str) -> String {
390    self.0.strftime(fmt).to_string()
391  }
392
393  /// Compare instants: strictly before `other`.
394  #[inline]
395  pub fn less_than(&self, other: &Self) -> bool {
396    self.0.timestamp() < other.0.timestamp()
397  }
398
399  /// Compare instants: strictly after `other`.
400  #[inline]
401  pub fn greater_than(&self, other: &Self) -> bool {
402    self.0.timestamp() > other.0.timestamp()
403  }
404
405  /// Inclusive range check on the underlying instants.
406  #[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// ── AnyDateTime ──────────────────────────────────────────────────────────────
414
415/// Either UTC or zone-aware wall time.
416#[derive(Debug, Clone, PartialEq, Eq, Hash)]
417pub enum AnyDateTime {
418  /// UTC instant.
419  Utc(UtcDateTime),
420  /// Zone-aware instant.
421  Zoned(ZonedDateTime),
422}
423
424// ── timezone ─────────────────────────────────────────────────────────────────
425
426/// UTC, fixed-offset, and IANA named zones ([`TimeZone`] helpers).
427pub mod timezone {
428  use super::*;
429
430  /// Invalid IANA identifier (or other lookup failure) for [`named`].
431  #[derive(Debug, Clone, PartialEq, Eq)]
432  pub struct TimeZoneError {
433    /// Input string that could not be resolved to a zone.
434    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  /// The static UTC zone ([`TimeZone::UTC`]).
446  #[inline]
447  pub fn utc() -> TimeZone {
448    TimeZone::UTC
449  }
450
451  /// Fixed offset from UTC in whole minutes (not IANA; no DST).
452  #[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  /// Resolve a named zone from the bundled IANA database.
461  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  /// Parse `Europe/London` via IANA lookup, or a numeric offset like `+01:00` / `-0530`.
471  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// ── UTC helpers ──────────────────────────────────────────────────────────────
484
485#[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
557// ── Zoned helpers ────────────────────────────────────────────────────────────
558
559fn 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  // ── New tests targeting previously-uncovered lines ──────────────────────────
778
779  #[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    // Far-future instants are not representable on every OS `SystemTime` (e.g. Windows
803    // FILETIME); `SystemTime + Duration` panics on overflow — use `checked_add`.
804    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    // May or may not be out of range depending on jiff version; just ensure no panic.
809    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    // 2024-07-04T12:34:56Z  →  ms = 0.2.096496000
830    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  // ── New tests: AnyDateTime ──────────────────────────────────────────────
858
859  #[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  // ── New tests: ZonedDateTime comparison helpers ─────────────────────────
880
881  #[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  // ── New tests: ZonedDateTime::distance_duration ─────────────────────────
894
895  #[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    // Also reversed (absolute)
902    let d2 = b.distance_duration(&a);
903    assert_eq!(d2, std::time::Duration::from_secs(5));
904  }
905
906  // ── New tests: TimeZoneError Display ───────────────────────────────────
907
908  #[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  // ── New tests: from_epoch_millis out-of-range ───────────────────────────
920
921  #[test]
922  fn utc_from_epoch_millis_out_of_range_returns_none() {
923    // i64::MAX is past jiff's supported range
924    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  // ── New tests: parse_fixed_offset_time_zone edge cases ─────────────────
935
936  #[test]
937  fn timezone_from_str_fixed_offset_edge_cases() {
938    // Explicit zero offset
939    assert!(timezone::from_str("+00:00").is_some());
940    assert!(timezone::from_str("-00:00").is_some());
941    // Hour only (no minutes)
942    assert!(timezone::from_str("+05").is_some());
943    assert!(timezone::from_str("-09").is_some());
944    // With seconds component
945    assert!(timezone::from_str("+01:30:00").is_some());
946    // Empty after sign → None
947    assert!(timezone::from_str("+").is_none());
948    assert!(timezone::from_str("-").is_none());
949    // Too many colons → None
950    assert!(timezone::from_str("+01:00:00:00").is_none());
951    // Non-numeric hour → None
952    assert!(timezone::from_str("+xx:00").is_none());
953  }
954
955  // ── New tests: named timezone success path ──────────────────────────────
956
957  #[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  // ── New tests: ZonedDateTime start/end/nearest broader units ───────────
967
968  #[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  // ── New tests: UtcDateTime end_of ──────────────────────────────────────
980
981  #[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}