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}