Skip to main content

deep_time/ymdhms/
mod.rs

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