Skip to main content

deep_time/time_parts/
to_deep_time.rs

1use crate::tzdb::offset_info_at_local;
2use crate::{
3    Dt, JD_2000_2_451_545, SEC_PER_DAYI64, Scale, TAI_SECS_1970_MIDNIGHT_TO_2000_NOON, an_err,
4    error::{DtErr, DtErrKind},
5    {Meridiem, Offset, TimeParts, Weekday},
6};
7
8impl TimeParts {
9    /// Converts [`TimeParts`] → [`Dt`].
10    /// - Resulting [`Dt`] is on the TAI timescale.
11    /// - If this [`TimeParts`] has a unix timestamp then it is used
12    ///   instead of anything else.
13    pub fn to_dt(&self) -> Result<Dt, DtErr> {
14        // ──────────────────────────────────────────────────────────────
15        // Fast path: explicit Unix timestamp
16        // ──────────────────────────────────────────────────────────────
17        if let Some(unix_secs) = self.unix_timestamp_seconds {
18            let sec = unix_secs - TAI_SECS_1970_MIDNIGHT_TO_2000_NOON;
19            let subsec = self.attos.unwrap_or(0);
20            return Ok(Dt::from(sec, subsec, Scale::UTC));
21        }
22
23        // ──────────────────────────────────────────────────────────────
24        // Civil date path
25        // ──────────────────────────────────────────────────────────────
26        let mut jd: Option<i64> = None;
27
28        if let Some(year) = self.yr {
29            if let (Some(m), Some(d)) = (self.mo, self.day) {
30                // Classic YMD – highest priority + full validation
31                if !Dt::is_valid_ymd(year, m, d) {
32                    return Err(an_err!(DtErrKind::InvalidInput, "ymd"));
33                }
34                jd = Some(Dt::ymd_to_jd(year, m, d));
35            } else if let Some(doy) = self.day_of_yr {
36                // Ordinal date (%j) – already validated
37                if doy == 0 || doy > 366 || (doy == 366 && !Dt::is_leap_yr(year)) {
38                    return Err(an_err!(DtErrKind::OutOfRange, "day of year"));
39                }
40                jd = Some(Dt::ydoy_to_jd(year, doy));
41            }
42        }
43
44        if jd.is_none() {
45            if let (Some(iso_y), Some(iso_w)) = (self.iso_wk_yr, self.iso_wk) {
46                // ISO week date (%G/%V)
47                if iso_w == 0 || iso_w > 53 {
48                    return Err(an_err!(DtErrKind::OutOfRange, "iso week"));
49                }
50                if iso_w == 53 && !Dt::has_iso_wk_53(iso_y) {
51                    return Err(an_err!(DtErrKind::InvalidItem, "iso week"));
52                }
53                let wd = self.wkday.unwrap_or(Weekday::Monday);
54                jd = Some(Dt::ymd_to_jd_from_iso_wk(iso_y, iso_w, wd));
55            } else if let (Some(y), Some(w)) = (self.yr, self.wk_sun) {
56                // Sunday-based week (%U)
57                if w > 53 {
58                    return Err(an_err!(DtErrKind::OutOfRange, "week number"));
59                }
60                let wd = self.wkday.unwrap_or(Weekday::Sunday);
61                jd = Some(Dt::ymd_to_jd_from_wk_sun(y, w, wd));
62            } else if let (Some(y), Some(w)) = (self.yr, self.wk_mon) {
63                // Monday-based week (%W)
64                if w > 53 {
65                    return Err(an_err!(DtErrKind::OutOfRange, "week number"));
66                }
67                let wd = self.wkday.unwrap_or(Weekday::Monday);
68                jd = Some(Dt::ymd_to_jd_from_wk_mon(y, w, wd));
69            }
70        }
71
72        let Some(jd) = jd else {
73            if self.yr.is_none() && self.iso_wk_yr.is_none() {
74                return Err(an_err!(DtErrKind::Incomplete, "no year"));
75            } else {
76                return Err(an_err!(DtErrKind::InvalidInput, "could not create julian"));
77            }
78        };
79
80        // ──────────────────────────────────────────────────────────────
81        // Resolve 12-hour time + meridiem (AM/PM) to 24-hour hour
82        // ──────────────────────────────────────────────────────────────
83        let hour = match (self.hr, self.meridiem) {
84            (Some(h), Some(m)) => {
85                if !(1..=12).contains(&h) {
86                    return Err(an_err!(DtErrKind::OutOfRange, "hour: {}", h));
87                }
88                match (h, m) {
89                    (12, Meridiem::AM) => 0,
90                    (12, Meridiem::PM) => 12,
91                    (h, Meridiem::AM) => h,
92                    (h, Meridiem::PM) => h + 12,
93                }
94            }
95            (Some(h), None) => h,
96            (None, _) => 0,
97        };
98
99        let minute = self.min.unwrap_or(0) as i64;
100        let second = self.sec.unwrap_or(0) as i64;
101        let days_since_j2000 = jd.saturating_sub(JD_2000_2_451_545);
102        let seconds_from_noon_utc = (hour as i64 - 12) * 3600 + minute * 60 + second;
103        let mut sec_utc: i64 = days_since_j2000
104            .saturating_mul(SEC_PER_DAYI64)
105            .saturating_add(seconds_from_noon_utc);
106
107        // ──────────────────────────────────────────────────────────────
108        // Apply timezone correction (IANA or Fixed offset)
109        // ──────────────────────────────────────────────────────────────
110
111        if let Some(name) = &self.iana_name {
112            let name_str = name.as_str().map_err(|e| {
113                an_err!(
114                    DtErrKind::InvalidBytes,
115                    "invalid iana ascii: {:?}: {}",
116                    name,
117                    e
118                )
119            })?;
120
121            if !name_str.is_empty() {
122                let provisional_unix = sec_utc.saturating_add(TAI_SECS_1970_MIDNIGHT_TO_2000_NOON);
123                match offset_info_at_local(name_str, provisional_unix) {
124                    Some(info) => {
125                        if info.is_gap {
126                            // Non-existent time (spring-forward gap) — shift forward
127                            sec_utc = sec_utc.saturating_add(info.gap_size); // shift local time into the valid post-gap period
128                            sec_utc = sec_utc.saturating_sub(info.offset as i64); // apply the post-jump offset
129                        } else {
130                            sec_utc = sec_utc.saturating_sub(info.offset as i64);
131                        }
132                    }
133                    None => {
134                        return Err(an_err!(
135                            DtErrKind::InvalidTimezoneOffset,
136                            "invalid iana: {}",
137                            name_str
138                        ));
139                    }
140                }
141            }
142        } else if let Some(Offset::Fixed(offset)) = self.offset {
143            // local civil time → true UTC instant
144            sec_utc = sec_utc.saturating_sub(offset as i64);
145        }
146        Ok(Dt::from(sec_utc, self.attos.unwrap_or(0), Scale::UTC))
147    }
148}