Skip to main content

ferroui_base/utilities/
date_time_offset.rs

1//! A point in time as a date and time of day together with its offset from
2//! UTC (.NET `System.DateTimeOffset`).
3
4use super::date_time_format;
5use super::{CultureInfo, DateTime, DateTimeFormatProvider, DateTimeKind, DayOfWeek, FormatError, TimeZoneInfo};
6use crate::animation::TimeSpan;
7use std::cmp::Ordering;
8use std::fmt;
9use std::hash::{Hash, Hasher};
10use std::ops::{Add, AddAssign, Sub, SubAssign};
11
12const OUT_OF_RANGE: &str = "The UTC time represented when the offset is applied must be between year 0 and 10,000.";
13
14/// A date and time of day relative to UTC.
15///
16/// Equality, ordering and hashing consider the UTC instant only: two values
17/// with different offsets that name the same instant are equal, as in .NET
18/// (use [`equals_exact`](Self::equals_exact) to compare the offsets too).
19#[derive(Clone, Copy)]
20pub struct DateTimeOffset {
21    /// The UTC instant; its kind is always unspecified.
22    utc: DateTime,
23    offset_minutes: i16,
24}
25
26impl DateTimeOffset {
27    /// The smallest value, 0001-01-01T00:00:00 +00:00.
28    pub const MIN_VALUE: DateTimeOffset = DateTimeOffset { utc: DateTime::MIN_VALUE, offset_minutes: 0 };
29    /// The largest value, 9999-12-31T23:59:59.9999999 +00:00.
30    pub const MAX_VALUE: DateTimeOffset = DateTimeOffset { utc: DateTime::MAX_VALUE, offset_minutes: 0 };
31    /// 1970-01-01T00:00:00 +00:00.
32    pub const UNIX_EPOCH: DateTimeOffset =
33        DateTimeOffset { utc: DateTime::UNIX_EPOCH.specify_kind(DateTimeKind::Unspecified), offset_minutes: 0 };
34
35    fn validate_offset(offset: TimeSpan) -> Result<i16, &'static str> {
36        let ticks = offset.ticks();
37        if ticks % TimeSpan::TICKS_PER_MINUTE != 0 {
38            return Err("Offset must be specified in whole minutes. (Parameter 'offset')");
39        }
40        if ticks.abs() > TimeZoneInfo::MAX_OFFSET.ticks() {
41            return Err("Offset must be within plus or minus 14 hours. (Parameter 'offset')");
42        }
43        Ok((ticks / TimeSpan::TICKS_PER_MINUTE) as i16)
44    }
45
46    fn from_clock(clock: DateTime, offset: TimeSpan) -> Result<DateTimeOffset, &'static str> {
47        let offset_minutes = Self::validate_offset(offset)?;
48        let utc = DateTime::try_from_ticks(clock.ticks() - offset.ticks(), DateTimeKind::Unspecified).ok_or(OUT_OF_RANGE)?;
49        Ok(DateTimeOffset { utc, offset_minutes })
50    }
51
52    /// The given clock date and time at `offset` from UTC. Panics when the
53    /// date and time are invalid, the offset is not a whole number of
54    /// minutes within 14 hours of UTC, or the UTC instant is out of range.
55    #[track_caller]
56    pub fn new(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: i32, offset: TimeSpan) -> DateTimeOffset {
57        let clock = DateTime::new_with_time(year, month, day, hour, minute, second);
58        Self::from_clock(clock, offset).unwrap_or_else(|message| panic!("{message}"))
59    }
60
61    /// Like [`new`](Self::new); `None` instead of a panic.
62    pub fn try_new(
63        year: i32,
64        month: i32,
65        day: i32,
66        hour: i32,
67        minute: i32,
68        second: i32,
69        offset: TimeSpan,
70    ) -> Option<DateTimeOffset> {
71        let clock = DateTime::try_new_with_time(year, month, day, hour, minute, second)?;
72        Self::from_clock(clock, offset).ok()
73    }
74
75    /// The clock time with `ticks` ticks at `offset` from UTC. Panics when
76    /// the ticks, the offset or the UTC instant are out of range.
77    #[track_caller]
78    pub fn from_ticks(ticks: i64, offset: TimeSpan) -> DateTimeOffset {
79        Self::from_clock(DateTime::from_ticks(ticks), offset).unwrap_or_else(|message| panic!("{message}"))
80    }
81
82    /// The instant of a [`DateTime`]: a UTC value gets the offset zero, a
83    /// local or unspecified value the local offset at that time
84    /// (.NET `new DateTimeOffset(DateTime)`). Panics when the UTC instant is
85    /// out of range.
86    #[track_caller]
87    pub fn from_date_time(date_time: DateTime) -> DateTimeOffset {
88        let offset = if date_time.kind() == DateTimeKind::Utc {
89            TimeSpan::ZERO
90        } else {
91            TimeZoneInfo::get_utc_offset_of_local_time(date_time)
92        };
93        Self::from_clock(date_time, offset).unwrap_or_else(|message| panic!("{message}"))
94    }
95
96    /// The clock time `date_time` at `offset` from UTC
97    /// (.NET `new DateTimeOffset(DateTime, TimeSpan)`). Panics when the offset
98    /// is invalid, when a UTC value is given a non-zero offset, when a local
99    /// value is given an offset other than the local one, or when the UTC
100    /// instant is out of range.
101    #[track_caller]
102    pub fn from_date_time_offset(date_time: DateTime, offset: TimeSpan) -> DateTimeOffset {
103        match date_time.kind() {
104            DateTimeKind::Utc => assert!(offset == TimeSpan::ZERO, "The UTC Offset for Utc DateTime instances must be 0. (Parameter 'offset')"),
105            DateTimeKind::Local => assert!(
106                offset == TimeZoneInfo::get_utc_offset_of_local_time(date_time),
107                "The UTC Offset of the local dateTime parameter does not match the offset argument. (Parameter 'offset')"
108            ),
109            DateTimeKind::Unspecified => {}
110        }
111        Self::from_clock(date_time, offset).unwrap_or_else(|message| panic!("{message}"))
112    }
113
114    /// The current date and time with the local offset.
115    pub fn now() -> DateTimeOffset {
116        Self::utc_now().to_local_time()
117    }
118
119    /// The current date and time with the offset zero.
120    pub fn utc_now() -> DateTimeOffset {
121        DateTimeOffset { utc: DateTime::utc_now().specify_kind(DateTimeKind::Unspecified), offset_minutes: 0 }
122    }
123
124    // --- components ---
125
126    /// The offset from UTC.
127    #[inline]
128    pub const fn offset(self) -> TimeSpan {
129        TimeSpan::from_ticks(self.offset_minutes as i64 * TimeSpan::TICKS_PER_MINUTE)
130    }
131
132    /// The clock date and time, of unspecified kind (.NET `DateTime`).
133    #[inline]
134    pub const fn date_time(self) -> DateTime {
135        // The constructors keep both the UTC instant and the clock time in range.
136        DateTime::from_ticks(self.utc.ticks() + self.offset().ticks())
137    }
138
139    /// The UTC date and time, of kind UTC (.NET `UtcDateTime`).
140    #[inline]
141    pub const fn utc_date_time(self) -> DateTime {
142        self.utc.specify_kind(DateTimeKind::Utc)
143    }
144
145    /// The date and time in local time, of kind local (.NET `LocalDateTime`).
146    pub fn local_date_time(self) -> DateTime {
147        self.utc_date_time().to_local_time()
148    }
149
150    /// The clock date at midnight, of unspecified kind.
151    #[inline]
152    pub const fn date(self) -> DateTime {
153        self.date_time().date()
154    }
155
156    /// The clock time elapsed since midnight.
157    #[inline]
158    pub const fn time_of_day(self) -> TimeSpan {
159        self.date_time().time_of_day()
160    }
161
162    /// The ticks of the clock date and time.
163    #[inline]
164    pub const fn ticks(self) -> i64 {
165        self.date_time().ticks()
166    }
167
168    /// The ticks of the UTC date and time.
169    #[inline]
170    pub const fn utc_ticks(self) -> i64 {
171        self.utc.ticks()
172    }
173
174    /// The year of the clock date.
175    pub fn year(self) -> i32 {
176        self.date_time().year()
177    }
178
179    /// The month of the clock date, 1..=12.
180    pub fn month(self) -> i32 {
181        self.date_time().month()
182    }
183
184    /// The day of the month of the clock date, 1..=31.
185    pub fn day(self) -> i32 {
186        self.date_time().day()
187    }
188
189    /// The day of the year of the clock date, 1..=366.
190    pub fn day_of_year(self) -> i32 {
191        self.date_time().day_of_year()
192    }
193
194    /// The day of the week of the clock date.
195    pub fn day_of_week(self) -> DayOfWeek {
196        self.date_time().day_of_week()
197    }
198
199    /// The hour of the clock time, 0..=23.
200    pub const fn hour(self) -> i32 {
201        self.date_time().hour()
202    }
203
204    /// The minute of the clock time, 0..=59.
205    pub const fn minute(self) -> i32 {
206        self.date_time().minute()
207    }
208
209    /// The second of the clock time, 0..=59.
210    pub const fn second(self) -> i32 {
211        self.date_time().second()
212    }
213
214    /// The millisecond of the clock time, 0..=999.
215    pub const fn millisecond(self) -> i32 {
216        self.date_time().millisecond()
217    }
218
219    // --- arithmetic ---
220
221    #[track_caller]
222    fn with_clock(self, clock: DateTime) -> DateTimeOffset {
223        Self::from_clock(clock, self.offset()).unwrap_or_else(|message| panic!("{message}"))
224    }
225
226    /// The value `value` later, with the same offset. Panics when out of range.
227    #[track_caller]
228    pub fn add(self, value: TimeSpan) -> DateTimeOffset {
229        self.with_clock(self.date_time().add(value))
230    }
231
232    /// The value `value` earlier, with the same offset. Panics when out of range.
233    #[track_caller]
234    pub fn subtract(self, value: TimeSpan) -> DateTimeOffset {
235        self.with_clock(self.date_time().subtract(value))
236    }
237
238    /// The interval from `other` to this value (by UTC instants).
239    #[inline]
240    pub fn subtract_date_time_offset(self, other: DateTimeOffset) -> TimeSpan {
241        self.utc - other.utc
242    }
243
244    /// The value `value` days later. Panics when out of range.
245    #[track_caller]
246    pub fn add_days(self, value: f64) -> DateTimeOffset {
247        self.with_clock(self.date_time().add_days(value))
248    }
249
250    /// The value `months` months later (see [`DateTime::add_months`]).
251    #[track_caller]
252    pub fn add_months(self, months: i32) -> DateTimeOffset {
253        self.with_clock(self.date_time().add_months(months))
254    }
255
256    /// The value `years` years later (see [`DateTime::add_years`]).
257    #[track_caller]
258    pub fn add_years(self, years: i32) -> DateTimeOffset {
259        self.with_clock(self.date_time().add_years(years))
260    }
261
262    // --- conversion ---
263
264    /// The same instant at another offset. Panics when the offset is invalid
265    /// or the clock time at that offset is out of range.
266    #[track_caller]
267    pub fn to_offset(self, offset: TimeSpan) -> DateTimeOffset {
268        let offset_minutes = Self::validate_offset(offset).unwrap_or_else(|message| panic!("{message}"));
269        assert!(DateTime::try_from_ticks(self.utc.ticks() + offset.ticks(), DateTimeKind::Unspecified).is_some(), "{OUT_OF_RANGE}");
270        DateTimeOffset { utc: self.utc, offset_minutes }
271    }
272
273    /// The same instant at the local offset. A clock time that would leave
274    /// the range keeps the instant and gets the offset zero.
275    pub fn to_local_time(self) -> DateTimeOffset {
276        let offset = TimeZoneInfo::get_local_utc_offset(self.utc_date_time());
277        match DateTime::try_from_ticks(self.utc.ticks() + offset.ticks(), DateTimeKind::Unspecified) {
278            Some(_) => DateTimeOffset { utc: self.utc, offset_minutes: (offset.ticks() / TimeSpan::TICKS_PER_MINUTE) as i16 },
279            None => DateTimeOffset { utc: self.utc, offset_minutes: 0 },
280        }
281    }
282
283    /// The same instant at the offset zero.
284    #[inline]
285    pub const fn to_universal_time(self) -> DateTimeOffset {
286        DateTimeOffset { utc: self.utc, offset_minutes: 0 }
287    }
288
289    // --- comparison ---
290
291    /// Compares two values by their UTC instants: -1, 0 or 1.
292    pub fn compare(first: DateTimeOffset, second: DateTimeOffset) -> i32 {
293        DateTime::compare(first.utc, second.utc)
294    }
295
296    /// Compares this value with `other` by their UTC instants: -1, 0 or 1.
297    pub fn compare_to(self, other: DateTimeOffset) -> i32 {
298        Self::compare(self, other)
299    }
300
301    /// Whether both the UTC instant and the offset are the same.
302    pub fn equals_exact(self, other: DateTimeOffset) -> bool {
303        self.utc == other.utc && self.offset_minutes == other.offset_minutes
304    }
305
306    // --- formatting ---
307
308    /// Formats with a standard or custom .NET date and time format string
309    /// and the conventions of `provider`. An empty format gives the short
310    /// date, the long time and the offset (`"G"` followed by `zzz`). Panics
311    /// when the format string is invalid (.NET throws `FormatException`).
312    #[track_caller]
313    pub fn to_string_format(self, format: &str, provider: &(impl DateTimeFormatProvider + ?Sized)) -> String {
314        match self.try_to_string_format(format, provider) {
315            Ok(text) => text,
316            Err(error) => panic!("{error}"),
317        }
318    }
319
320    /// Like [`to_string_format`](Self::to_string_format), but gives the error
321    /// of an invalid format string.
322    pub fn try_to_string_format(
323        self,
324        format: &str,
325        provider: &(impl DateTimeFormatProvider + ?Sized),
326    ) -> Result<String, FormatError> {
327        date_time_format::format(self.date_time(), Some(self.offset()), format, &provider.get_date_time_format())
328    }
329
330    /// Formats with a format string and the conventions of the current
331    /// culture (.NET `ToString(string)`). Panics when the format is invalid.
332    #[track_caller]
333    pub fn to_string_with(self, format: &str) -> String {
334        self.to_string_format(format, &CultureInfo::current_culture())
335    }
336
337    /// Formats with the default format and the conventions of `provider`
338    /// (.NET `ToString(IFormatProvider)`).
339    pub fn to_string_provider(self, provider: &(impl DateTimeFormatProvider + ?Sized)) -> String {
340        self.to_string_format("", provider)
341    }
342}
343
344impl std::str::FromStr for DateTimeOffset {
345    type Err = FormatError;
346
347    /// [`DateTimeOffset::parse`]: the invariant culture.
348    fn from_str(text: &str) -> Result<Self, Self::Err> {
349        DateTimeOffset::parse(text, &CultureInfo::invariant_culture())
350    }
351}
352
353impl DateTimeOffset {
354    // --- parsing ---
355
356    /// Parses a date and time with an optional offset written in the
357    /// conventions of `provider` (.NET
358    /// `DateTimeOffset.Parse(string, IFormatProvider)`): the forms
359    /// [`DateTime::parse`] accepts. The value has the offset the text
360    /// states; a text without an offset is a local time and gets the local
361    /// offset at that time, and a text without a date the current local
362    /// date, as in .NET. The offset is `Z`, `GMT`, or a signed `hh:mm`,
363    /// `hhmm` or `h[h]` after the time or the date. An offset of more than
364    /// 14 hours and an instant outside of the years 1 to 9999 are errors.
365    ///
366    /// Checked against results recorded from .NET for the invariant culture
367    /// (`date_time_tests.rs`, `date_time_offset_parse_matches_net`). Not
368    /// supported, as for [`DateTime::parse`]: what the heuristic parser of
369    /// .NET accepts beyond those forms (other time zone names, era names,
370    /// genitive month names), the `DateTimeStyles` overloads and
371    /// `ParseExact`.
372    pub fn parse(text: &str, provider: &(impl DateTimeFormatProvider + ?Sized)) -> Result<DateTimeOffset, FormatError> {
373        let parsed =
374            date_time_format::parse(text, &provider.get_date_time_format(), super::DateTimeStyles::NONE)?;
375        let clock = parsed.date_time.specify_kind(DateTimeKind::Unspecified);
376        let offset = match parsed.offset {
377            Some(offset) => offset,
378            None => TimeZoneInfo::get_utc_offset_of_local_time(clock),
379        };
380        if Self::validate_offset(offset).is_err() {
381            return Err(FormatError::from_string(format!(
382                "The time zone offset of string '{text}' must be within plus or minus 14 hours."
383            )));
384        }
385        Self::from_clock(clock, offset).map_err(|_| {
386            FormatError::from_string(format!(
387                "The UTC representation of the date '{text}' falls outside the year range 1-9999."
388            ))
389        })
390    }
391
392    /// Parses like [`parse`](Self::parse); `None` on failure
393    /// (.NET `DateTimeOffset.TryParse`).
394    pub fn try_parse(text: &str, provider: &(impl DateTimeFormatProvider + ?Sized)) -> Option<DateTimeOffset> {
395        Self::parse(text, provider).ok()
396    }
397}
398
399impl Default for DateTimeOffset {
400    /// [`DateTimeOffset::MIN_VALUE`], the C# `default(DateTimeOffset)`.
401    fn default() -> Self {
402        DateTimeOffset::MIN_VALUE
403    }
404}
405
406impl From<DateTime> for DateTimeOffset {
407    /// The C# implicit conversion; see [`DateTimeOffset::from_date_time`].
408    #[track_caller]
409    fn from(value: DateTime) -> Self {
410        DateTimeOffset::from_date_time(value)
411    }
412}
413
414impl PartialEq for DateTimeOffset {
415    #[inline]
416    fn eq(&self, other: &Self) -> bool {
417        self.utc == other.utc
418    }
419}
420
421impl Eq for DateTimeOffset {}
422
423impl PartialOrd for DateTimeOffset {
424    #[inline]
425    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
426        Some(self.cmp(other))
427    }
428}
429
430impl Ord for DateTimeOffset {
431    #[inline]
432    fn cmp(&self, other: &Self) -> Ordering {
433        self.utc.cmp(&other.utc)
434    }
435}
436
437impl Hash for DateTimeOffset {
438    fn hash<H: Hasher>(&self, state: &mut H) {
439        self.utc.hash(state);
440    }
441}
442
443impl Add<TimeSpan> for DateTimeOffset {
444    type Output = DateTimeOffset;
445    #[track_caller]
446    fn add(self, rhs: TimeSpan) -> DateTimeOffset {
447        DateTimeOffset::add(self, rhs)
448    }
449}
450
451impl Sub<TimeSpan> for DateTimeOffset {
452    type Output = DateTimeOffset;
453    #[track_caller]
454    fn sub(self, rhs: TimeSpan) -> DateTimeOffset {
455        self.subtract(rhs)
456    }
457}
458
459impl Sub<DateTimeOffset> for DateTimeOffset {
460    type Output = TimeSpan;
461    #[inline]
462    fn sub(self, rhs: DateTimeOffset) -> TimeSpan {
463        self.subtract_date_time_offset(rhs)
464    }
465}
466
467impl AddAssign<TimeSpan> for DateTimeOffset {
468    #[track_caller]
469    fn add_assign(&mut self, rhs: TimeSpan) {
470        *self = *self + rhs;
471    }
472}
473
474impl SubAssign<TimeSpan> for DateTimeOffset {
475    #[track_caller]
476    fn sub_assign(&mut self, rhs: TimeSpan) {
477        *self = *self - rhs;
478    }
479}
480
481impl fmt::Display for DateTimeOffset {
482    /// The short date, long time and offset of the current culture (.NET `ToString()`).
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        f.write_str(&self.to_string_format("", &CultureInfo::current_culture()))
485    }
486}
487
488impl fmt::Debug for DateTimeOffset {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        let clock = self.date_time();
491        let (year, month, day, _) = clock.date_parts();
492        let minutes = self.offset_minutes.unsigned_abs();
493        write!(
494            f,
495            "DateTimeOffset({year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:07}{}{:02}:{:02})",
496            clock.hour(),
497            clock.minute(),
498            clock.second(),
499            clock.ticks() % TimeSpan::TICKS_PER_SECOND,
500            if self.offset_minutes < 0 { '-' } else { '+' },
501            minutes / 60,
502            minutes % 60
503        )
504    }
505}