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}