Skip to main content

deep_time/ymdhms/
mod.rs

1use crate::{ATTOS_PER_SEC_I128, Dt, Scale};
2
3#[cfg(any(feature = "jiff-tz-bundle", feature = "jiff-tz"))]
4use crate::{DtErr, DtErrKind, an_err};
5#[cfg(any(feature = "jiff-tz-bundle", feature = "jiff-tz"))]
6use jiff::civil;
7
8/// Combined date + time object.
9///
10/// Has calendar aware and, when the `jiff-tz` feature is enabled,
11/// timezone aware math functions.
12///
13/// ## Examples
14///
15/// **Creating a** [`YmdHms`].
16///
17/// ```rust
18/// use deep_time::{Dt, Scale};
19///
20/// // clamped to 29
21/// let x = Dt::from_ymd(2000, 2, 30, Scale::UTC, 0, 0, 0, 0).to_ymd();
22///
23/// assert_eq!(x.day(), 29);
24/// ```
25///
26/// **Adding a year.** 2000 is a leap year and Feb. 29th is possible, but
27/// 2001 isn't a leap year so the day is clamped to the 28th.
28///
29/// ```rust
30/// use deep_time::{Dt, Scale};
31///
32/// let x = Dt::from_ymd(2000, 2, 29, Scale::UTC, 0, 0, 0, 0).to_ymd();
33/// let x = x.add_yr(1);
34///
35/// assert_eq!(x.day(), 28);
36/// ```
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39#[cfg_attr(feature = "tsify", derive(tsify::Tsify))]
40#[cfg_attr(feature = "defmt", derive(defmt::Format))]
41pub struct YmdHms {
42    pub(crate) yr: i64,
43    pub(crate) mo: u8,
44    pub(crate) day: u8,
45    pub(crate) hr: u8,
46    pub(crate) min: u8,
47    pub(crate) sec: u8,    // 0–60 (60 only during leap seconds)
48    pub(crate) attos: u64, // attoseconds (0 ≤ subsec < 10¹⁸)
49    pub(crate) dt: Dt,
50}
51
52impl YmdHms {
53    /// Create a new [`YmdHms`], wrapper around
54    /// [`Dt::from_ymd`](../struct.Dt.html#method.from_ymd).
55    #[inline(always)]
56    pub const fn new(
57        yr: i64,
58        mo: u8,
59        day: u8,
60        scale: Scale,
61        hr: u8,
62        min: u8,
63        sec: u8,
64        attos: u64,
65    ) -> YmdHms {
66        Dt::from_ymd(yr, mo, day, scale, hr, min, sec, attos).to_ymd()
67    }
68
69    /// Returns the [`Dt`] that was used to make this [`YmdHms`] object.
70    #[inline(always)]
71    pub const fn to_dt(&self) -> Dt {
72        self.dt
73    }
74
75    /// Internal helper that round-trips through [`Dt`] to obtain a normalized
76    /// `YmdHms` (handles clamping, leap seconds, etc.).
77    #[inline(always)]
78    const fn reconstruct(
79        &self,
80        yr: i64,
81        mo: u8,
82        day: u8,
83        hr: u8,
84        min: u8,
85        sec: u8,
86        attos: u64,
87    ) -> Self {
88        let mut ymd = Dt::from_ymd(yr, mo, day, self.dt.target, hr, min, sec, attos).to_ymd();
89        ymd.dt.scale = self.dt.scale;
90        ymd
91    }
92
93    /// Adds (or subtracts) whole years, preserving month and day-of-month.
94    /// - Uses standard last-day-of-month clamping.
95    /// - Negative values subtract.
96    pub const fn add_yr(&self, n: i64) -> Self {
97        if n == 0 {
98            return *self;
99        }
100        let new_yr = self.yr.saturating_add(n);
101        let max_day = Dt::days_in_month(new_yr, self.mo);
102        let new_day = Dt::clamp_u8(self.day, 1, max_day);
103        self.reconstruct(
104            new_yr, self.mo, new_day, self.hr, self.min, self.sec, self.attos,
105        )
106    }
107
108    /// Adds (or subtracts) calendar months. Negative values subtract.
109    pub const fn add_mo(&self, n: i64) -> Self {
110        if n == 0 {
111            return *self;
112        }
113
114        let yr = self.yr as i128;
115        let mo = self.mo as i128;
116        let delta = n as i128;
117
118        let total_months = yr * 12 + (mo - 1) + delta;
119
120        let new_yr = Dt::i128_to_i64(total_months.div_euclid(12));
121        let new_mo = Dt::clamp_u8((total_months.rem_euclid(12) + 1) as u8, 1, 12);
122
123        let max_day = Dt::days_in_month(new_yr, new_mo);
124        let new_day = Dt::clamp_u8(self.day, 1, max_day);
125
126        self.reconstruct(
127            new_yr, new_mo, new_day, self.hr, self.min, self.sec, self.attos,
128        )
129    }
130
131    /// Adds (or subtracts) calendar weeks. Negative values subtract.
132    #[inline(always)]
133    pub const fn add_wk(&self, n: i64) -> Self {
134        self.add_days(n.saturating_mul(7))
135    }
136
137    /// Adds (or subtracts) calendar days. Negative values subtract.
138    pub const fn add_days(&self, n: i64) -> Self {
139        if n == 0 {
140            return *self;
141        }
142        let jd = Dt::ymd_to_jd(self.yr, self.mo, self.day);
143        let new_jd = jd.saturating_add(n);
144        let (new_yr, new_mo, new_day) = Dt::jd_to_ymd(new_jd);
145        self.reconstruct(
146            new_yr, new_mo, new_day, self.hr, self.min, self.sec, self.attos,
147        )
148    }
149
150    /// Internal implementation detail for all sub-day / physical-time additions.
151    /// Creates a temporary [`Dt`], performs the addition, then converts back to `YmdHms`.
152    const fn _add_attos(&self, attos_delta: i128) -> Self {
153        let tai = Dt::from_ymd(
154            self.yr,
155            self.mo,
156            self.day,
157            self.dt.target,
158            self.hr,
159            self.min,
160            self.sec,
161            self.attos,
162        );
163        let mut ymd = tai.add(Dt::span(attos_delta)).to_ymd();
164        ymd.dt.scale = self.dt.scale;
165        ymd
166    }
167
168    /// Adds (or subtracts) attoseconds. Negative values subtract.
169    #[inline(always)]
170    pub const fn add_attos(&self, n: i128) -> Self {
171        self._add_attos(n)
172    }
173
174    /// Adds (or subtracts) whole seconds. Negative values subtract.
175    #[inline(always)]
176    pub const fn add_sec(&self, n: i64) -> Self {
177        self._add_attos((n as i128).saturating_mul(ATTOS_PER_SEC_I128))
178    }
179
180    /// Adds (or subtracts) whole minutes. Negative values subtract.
181    #[inline]
182    pub const fn add_min(&self, n: i64) -> Self {
183        let delta = (n as i128)
184            .saturating_mul(60)
185            .saturating_mul(ATTOS_PER_SEC_I128);
186        self._add_attos(delta)
187    }
188
189    /// Adds (or subtracts) whole hours. Negative values subtract.
190    #[inline]
191    pub const fn add_hr(&self, n: i64) -> Self {
192        let delta = (n as i128)
193            .saturating_mul(3600)
194            .saturating_mul(ATTOS_PER_SEC_I128);
195        self._add_attos(delta)
196    }
197
198    /// Returns the year component.
199    #[inline(always)]
200    pub const fn yr(&self) -> i64 {
201        self.yr
202    }
203
204    /// Returns the month component (1–12).
205    #[inline(always)]
206    pub const fn mo(&self) -> u8 {
207        self.mo
208    }
209
210    /// Returns the day-of-month component (1–31, depending on month/year).
211    #[inline(always)]
212    pub const fn day(&self) -> u8 {
213        self.day
214    }
215
216    /// Returns the hour component (0–23).
217    #[inline(always)]
218    pub const fn hr(&self) -> u8 {
219        self.hr
220    }
221
222    /// Returns the minute component (0–59).
223    #[inline(always)]
224    pub const fn min(&self) -> u8 {
225        self.min
226    }
227
228    /// Returns the second component (0–60). The value 60 only occurs during
229    /// a positive leap second on `Scale::UTC` / `UtcSpice` / `UtcHist`.
230    #[inline(always)]
231    pub const fn sec(&self) -> u8 {
232        self.sec
233    }
234
235    /// Returns the attosecond (sub-second) component (0 ≤ attos < 10¹⁸).
236    #[inline(always)]
237    pub const fn attos(&self) -> u64 {
238        self.attos
239    }
240
241    /// The time scale that the object was created on.
242    #[inline(always)]
243    pub const fn time_scale(&self) -> Scale {
244        self.dt.target
245    }
246
247    /// Returns the **ISO week year** (can differ from the calendar year near
248    /// January 1 / December 31).
249    #[inline(always)]
250    pub const fn iso_yr(&self) -> i64 {
251        let (iso_yr, _, _) = Dt::_to_iso_wk_date(self.yr, self.mo, self.day);
252        iso_yr
253    }
254
255    /// Returns the **ISO week number** (1–53). Weeks start on Monday; week 1
256    /// is the week containing the first Thursday of the year.
257    #[inline(always)]
258    pub const fn iso_wk(&self) -> u8 {
259        let (_, iso_wk, _) = Dt::_to_iso_wk_date(self.yr, self.mo, self.day);
260        iso_wk
261    }
262
263    /// Returns the **day of the year** (ordinal date), 1-based (Jan 1 = 1,
264    /// Dec 31 = 365 or 366 in leap years).
265    #[inline(always)]
266    pub const fn day_of_yr(&self) -> u16 {
267        Dt::_day_of_yr(self.yr, self.mo, self.day)
268    }
269
270    /// Returns the **weekday** number according to [`Dt::jd_to_wkday`]
271    /// (typically 0 = Sunday … 6 = Saturday; exact convention is defined
272    /// by the Julian Day helper).
273    #[inline(always)]
274    pub const fn wkday(&self) -> u8 {
275        let jd = Dt::ymd_to_jd(self.yr, self.mo, self.day);
276        Dt::jd_to_wkday(jd)
277    }
278
279    /// Returns the **week of year** number when weeks are considered to start
280    /// on Sunday (US-style numbering).
281    #[inline(always)]
282    pub const fn wk_of_yr_sun(&self) -> u8 {
283        Dt::_wk_sun(self.yr, self.day_of_yr())
284    }
285
286    /// Returns the **week of year** number when weeks are considered to start
287    /// on Monday.
288    #[inline(always)]
289    pub const fn wk_of_yr_mon(&self) -> u8 {
290        Dt::_wk_mon(self.yr, self.day_of_yr())
291    }
292}
293
294#[cfg(any(feature = "jiff-tz-bundle", feature = "jiff-tz"))]
295impl YmdHms {
296    /// Adds the given number of years in the specified IANA timezone,
297    /// respecting timezone rules (including DST) and proper calendar arithmetic.
298    ///
299    /// ## Errors
300    ///
301    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
302    ///   `-9999..=9999` range (checked before involving Jiff).
303    /// - Specific errors for invalid time components when preparing values for Jiff:
304    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
305    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
306    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
307    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
308    ///   would be outside the range supported by Jiff (the checked_add fails).
309    pub fn add_yr_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
310        let zoned = self
311            .to_jiff_zoned(tz)?
312            .checked_add(jiff::Span::new().years(n))
313            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
314        Ok(self.from_jiff_zoned(zoned))
315    }
316
317    /// Adds the given number of months in the specified IANA timezone,
318    /// respecting timezone rules and calendar month-end clamping.
319    ///
320    /// ## Errors
321    ///
322    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
323    ///   `-9999..=9999` range (checked before involving Jiff).
324    /// - Specific errors for invalid time components when preparing values for Jiff:
325    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
326    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
327    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
328    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
329    ///   would be outside the range supported by Jiff (the checked_add fails).
330    pub fn add_mo_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
331        let zoned = self
332            .to_jiff_zoned(tz)?
333            .checked_add(jiff::Span::new().months(n))
334            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
335        Ok(self.from_jiff_zoned(zoned))
336    }
337
338    /// Adds the given number of weeks in the specified IANA timezone.
339    ///
340    /// ## Errors
341    ///
342    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
343    ///   `-9999..=9999` range (checked before involving Jiff).
344    /// - Specific errors for invalid time components when preparing values for Jiff:
345    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
346    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
347    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
348    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
349    ///   would be outside the range supported by Jiff (the checked_add fails).
350    #[inline(always)]
351    pub fn add_wk_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
352        self.add_days_tz(n.saturating_mul(7), tz)
353    }
354
355    /// Adds the given number of calendar days in the specified IANA timezone.
356    ///
357    /// ## Errors
358    ///
359    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
360    ///   `-9999..=9999` range (checked before involving Jiff).
361    /// - Specific errors for invalid time components when preparing values for Jiff:
362    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
363    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
364    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
365    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
366    ///   would be outside the range supported by Jiff (the checked_add fails).
367    pub fn add_days_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
368        let zoned = self
369            .to_jiff_zoned(tz)?
370            .checked_add(jiff::Span::new().days(n))
371            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
372        Ok(self.from_jiff_zoned(zoned))
373    }
374
375    /// Adds the given number of hours in the specified IANA timezone,
376    /// respecting timezone rules (including DST).
377    ///
378    /// ## Errors
379    ///
380    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
381    ///   `-9999..=9999` range (checked before involving Jiff).
382    /// - Specific errors for invalid time components when preparing values for Jiff:
383    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
384    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
385    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
386    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
387    ///   would be outside the range supported by Jiff (the checked_add fails).
388    pub fn add_hr_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
389        let new_zoned = self
390            .to_jiff_zoned(tz)?
391            .checked_add(jiff::Span::new().hours(n))
392            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
393        Ok(self.from_jiff_zoned(new_zoned))
394    }
395
396    /// Adds the given number of minutes in the specified IANA timezone,
397    /// respecting timezone rules (including DST).
398    ///
399    /// ## Errors
400    ///
401    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
402    ///   `-9999..=9999` range (checked before involving Jiff).
403    /// - Specific errors for invalid time components when preparing values for Jiff:
404    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
405    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
406    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
407    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
408    ///   would be outside the range supported by Jiff (the checked_add fails).
409    pub fn add_min_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
410        let zoned = self
411            .to_jiff_zoned(tz)?
412            .checked_add(jiff::Span::new().minutes(n))
413            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
414        Ok(self.from_jiff_zoned(zoned))
415    }
416
417    /// Adds the given number of seconds in the specified IANA timezone.
418    ///
419    /// ## Errors
420    ///
421    /// - [`DtErrKind::YearOutOfRange`] if the year of the date is outside the
422    ///   `-9999..=9999` range (checked before involving Jiff).
423    /// - Specific errors for invalid time components when preparing values for Jiff:
424    ///   [`DtErrKind::InvalidHour`], [`DtErrKind::InvalidMinute`],
425    ///   [`DtErrKind::InvalidSecond`], [`DtErrKind::InvalidMonth`], or [`DtErrKind::InvalidDay`].
426    /// - [`DtErrKind::InvalidTimeZone`] if Jiff cannot find/resolve the IANA timezone name.
427    /// - [`DtErrKind::OutOfRange`] if the result of the calendar arithmetic operation
428    ///   would be outside the range supported by Jiff (the checked_add fails).
429    pub fn add_sec_tz(&self, n: i64, tz: &str) -> Result<Self, DtErr> {
430        let zoned = self
431            .to_jiff_zoned(tz)?
432            .checked_add(jiff::Span::new().seconds(n))
433            .map_err(|e| an_err!(DtErrKind::OutOfRange, "{}", e))?;
434        Ok(self.from_jiff_zoned(zoned))
435    }
436
437    // helpers
438
439    fn to_jiff_zoned(&self, tz: &str) -> Result<jiff::Zoned, DtErr> {
440        if !(-9999..=9999).contains(&self.yr) {
441            return Err(an_err!(DtErrKind::YearOutOfRange));
442        }
443
444        let hr: i8 = self
445            .hr
446            .try_into()
447            .map_err(|_| an_err!(DtErrKind::InvalidHour))?;
448        let min: i8 = self
449            .min
450            .try_into()
451            .map_err(|_| an_err!(DtErrKind::InvalidMinute))?;
452
453        let sec_for_jiff: i8 = if self.sec == 60 {
454            59
455        } else {
456            self.sec
457                .try_into()
458                .map_err(|_| an_err!(DtErrKind::InvalidSecond))?
459        };
460
461        let mo: i8 = self
462            .mo
463            .try_into()
464            .map_err(|_| an_err!(DtErrKind::InvalidMonth))?;
465        let day: i8 = self
466            .day
467            .try_into()
468            .map_err(|_| an_err!(DtErrKind::InvalidDay))?;
469
470        let civil_time = civil::date(self.yr as i16, mo, day).at(hr, min, sec_for_jiff, 0);
471
472        civil_time
473            .in_tz(tz)
474            .map_err(|e| an_err!(DtErrKind::InvalidTimeZone, "{}", e))
475    }
476
477    fn from_jiff_zoned(&self, zoned: jiff::Zoned) -> Self {
478        let civil = zoned.datetime();
479
480        self.reconstruct(
481            civil.year() as i64,
482            civil.month() as u8,
483            civil.day() as u8,
484            civil.hour() as u8,
485            civil.minute() as u8,
486            civil.second() as u8,
487            self.attos,
488        )
489    }
490}
491
492impl core::fmt::Display for YmdHms {
493    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
494        // Year: 4-digit padded when |yr| < 10000, natural width otherwise
495        if self.yr >= 0 {
496            if self.yr < 10000 {
497                core::write!(f, "{:04}", self.yr)?;
498            } else {
499                core::write!(f, "{}", self.yr)?;
500            }
501        } else {
502            let abs = (-self.yr) as u64;
503            if abs < 10000 {
504                core::write!(f, "-{:04}", abs)?;
505            } else {
506                core::write!(f, "-{}", abs)?;
507            }
508        }
509
510        // Month (pad only if < 10)
511        if self.mo < 10 {
512            core::write!(f, "-0{}", self.mo)?;
513        } else {
514            core::write!(f, "-{}", self.mo)?;
515        }
516
517        // Day (pad only if < 10)
518        if self.day < 10 {
519            core::write!(f, "-0{}", self.day)?;
520        } else {
521            core::write!(f, "-{}", self.day)?;
522        }
523
524        core::write!(f, "T")?;
525
526        // Hour (pad only if < 10)
527        if self.hr < 10 {
528            core::write!(f, "0{}", self.hr)?;
529        } else {
530            core::write!(f, "{}", self.hr)?;
531        }
532
533        core::write!(f, ":")?;
534
535        // Minute (pad only if < 10)
536        if self.min < 10 {
537            core::write!(f, "0{}", self.min)?;
538        } else {
539            core::write!(f, "{}", self.min)?;
540        }
541
542        core::write!(f, ":")?;
543
544        // Second (pad only if < 10) — 60 is still fine
545        if self.sec < 10 {
546            core::write!(f, "0{}", self.sec)?;
547        } else {
548            core::write!(f, "{}", self.sec)?;
549        }
550
551        // Fractional attoseconds
552        if self.attos != 0 {
553            let mut buf = [0u8; 18];
554            let mut n = self.attos;
555            for i in (0..18).rev() {
556                buf[i] = (n % 10) as u8 + b'0';
557                n /= 10;
558            }
559            let mut end = 18;
560            while end > 0 && buf[end - 1] == b'0' {
561                end -= 1;
562            }
563
564            core::write!(f, ".")?;
565            for &byte in &buf[..end] {
566                core::write!(f, "{}", byte as char)?;
567            }
568        }
569
570        // Scale abbreviation at the end
571        core::write!(f, " {}", self.dt.target.abbrev())
572    }
573}