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}