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