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