Skip to main content

deep_time/time_parts/
to_chrono.rs

1use crate::{
2    ATTOS_PER_NS, Dt, an_err,
3    error::{DtErr, DtErrKind},
4    {Meridiem, Offset, TimeParts, Weekday},
5};
6use chrono::{
7    DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone as ChronoTimeZone,
8};
9
10#[cfg(feature = "chrono-tz")]
11use chrono_tz::Tz;
12
13#[cfg(not(feature = "chrono-tz"))]
14use crate::tzdb::offset_info_at_local;
15
16impl TimeParts {
17    /// Converts [`TimeParts`] → [`chrono::NaiveDateTime`] (civil time, no TZ).
18    pub fn to_chrono_naive_datetime(&self) -> Result<NaiveDateTime, DtErr> {
19        let date = self.build_naive_date()?;
20        let time = self.build_naive_time()?;
21
22        Ok(date.and_time(time))
23    }
24
25    fn build_naive_date(&self) -> Result<NaiveDate, DtErr> {
26        // YMD (highest priority, matches Jiff fast-path)
27        if let (Some(y), Some(m), Some(d)) = (self.year, self.month, self.day) {
28            let year_i32: i32 = y
29                .try_into()
30                .map_err(|e| an_err!(DtErrKind::InvalidNumber, "year: {}: {}", y, e))?;
31            return NaiveDate::from_ymd_opt(year_i32, m as u32, d as u32)
32                .ok_or_else(|| an_err!(DtErrKind::InvalidInput, "ymd: {}-{}-{}", year_i32, m, d));
33        }
34
35        // Ordinal date (%j)
36        if let (Some(y), Some(doy)) = (self.year, self.day_of_year) {
37            let year_i32: i32 = y
38                .try_into()
39                .map_err(|e| an_err!(DtErrKind::InvalidNumber, "year: {}: {}", y, e))?;
40            return NaiveDate::from_yo_opt(year_i32, doy as u32)
41                .ok_or_else(|| an_err!(DtErrKind::InvalidInput, "ydoy: {}-{}", y, doy));
42        }
43
44        // Small helper: JDN → chrono NaiveDate
45        let jdn_to_naive_date = |jdn: i64| -> Result<NaiveDate, DtErr> {
46            let days_from_ce: i32 = (jdn - 1721425)
47                .try_into()
48                .map_err(|e| an_err!(DtErrKind::InvalidInput, "jdn: {}: {}", jdn, e))?;
49            NaiveDate::from_num_days_from_ce_opt(days_from_ce)
50                .ok_or_else(|| an_err!(DtErrKind::InvalidInput, "days_from_ce: {}", days_from_ce))
51        };
52
53        // ISO week date (%G/%V + weekday)
54        if let (Some(iso_y), Some(w)) = (self.iso_week_year, self.iso_week) {
55            let wd = self.weekday.unwrap_or(Weekday::Monday);
56            let jdn = Dt::ymd_to_jdn_from_iso_week(iso_y, w, wd);
57            return jdn_to_naive_date(jdn);
58        }
59
60        // Sunday-based week number (%U)
61        if let (Some(y), Some(w)) = (self.year, self.week_sun) {
62            let wd = self.weekday.unwrap_or(Weekday::Sunday);
63            let jdn = Dt::ymd_to_jdn_from_week_sun(y, w, wd);
64            return jdn_to_naive_date(jdn);
65        }
66
67        // Monday-based week number (%W)
68        if let (Some(y), Some(w)) = (self.year, self.week_mon) {
69            let wd = self.weekday.unwrap_or(Weekday::Monday);
70            let jdn = Dt::ymd_to_jdn_from_week_mon(y, w, wd);
71            return jdn_to_naive_date(jdn);
72        }
73
74        Err(an_err!(DtErrKind::InvalidInput, "failed to convert"))
75    }
76
77    fn build_naive_time(&self) -> Result<NaiveTime, DtErr> {
78        let mut hour = self.hour.unwrap_or(0) as u32;
79        let minute = self.minute.unwrap_or(0) as u32;
80        let mut second = self.second.unwrap_or(0) as u32;
81
82        if let Some(meridiem) = self.meridiem {
83            match (hour, meridiem) {
84                (12, Meridiem::AM) => hour = 0,
85                (12, Meridiem::PM) => {}
86                (h, Meridiem::PM) if h < 12 => hour = h + 12,
87                _ => {}
88            }
89        }
90
91        let raw_ns_u64 = if let Some(attos) = self.attos {
92            attos / ATTOS_PER_NS
93        } else {
94            0
95        };
96
97        let is_leap = second == 60 || self.is_leap_second;
98        if !is_leap && raw_ns_u64 > 999_999_999 {
99            return Err(an_err!(DtErrKind::OutOfRange, "leap ns: {}", raw_ns_u64));
100        }
101
102        let mut subsec_nano: u32 = if raw_ns_u64 > 1_999_999_999 {
103            1_999_999_999
104        } else {
105            raw_ns_u64 as u32
106        };
107
108        if is_leap {
109            second = 59;
110            subsec_nano = subsec_nano.saturating_add(1_000_000_000);
111            if subsec_nano > 1_999_999_999 {
112                subsec_nano = 1_999_999_999;
113            }
114        } else if second > 59 {
115            return Err(an_err!(DtErrKind::OutOfRange, "seconds: {}", second));
116        }
117
118        NaiveTime::from_hms_nano_opt(hour, minute, second, subsec_nano).ok_or_else(|| {
119            an_err!(
120                DtErrKind::InvalidInput,
121                "hms: {} {} {} {}",
122                hour,
123                minute,
124                second,
125                subsec_nano
126            )
127        })
128    }
129
130    /// Helper: resolve fixed offset / UTC only.
131    fn to_chrono_offset(&self) -> Result<FixedOffset, DtErr> {
132        match self.offset {
133            Some(Offset::Fixed(secs)) => FixedOffset::east_opt(secs)
134                .ok_or_else(|| an_err!(DtErrKind::InvalidTimezoneOffset, "offset secs: {}", secs)),
135            Some(Offset::Utc) | Some(Offset::None) | None => FixedOffset::east_opt(0)
136                .ok_or_else(|| an_err!(DtErrKind::InvalidTimezoneOffset, "offset secs: 0")),
137        }
138    }
139
140    /// Converts [`TimeParts`] → [`chrono::DateTime`].
141    /// - If this [`TimeParts`] has a unix timestamp then it is used
142    ///   instead of anything else, timezones are ignored in this route.
143    /// - If the `"chrono-tz"` feature is enabled then chronos tz features
144    ///   are used to parse the IANA name.
145    /// - If the `"chrono-tz"` feature is **not** enabled then the library's
146    ///   own tz handling will be used.
147    pub fn to_chrono_datetime(&self) -> Result<DateTime<FixedOffset>, DtErr> {
148        // ============================================================
149        // UNIX TIMESTAMP PATH
150        // Always UTC. Completely ignores offset + iana_name.
151        // ============================================================
152        if let Some(secs) = self.unix_timestamp_seconds {
153            let subsec_nano = if let Some(attos) = self.attos {
154                let ns_u64 = attos / ATTOS_PER_NS;
155                if ns_u64 > 999_999_999 {
156                    999_999_999
157                } else {
158                    ns_u64 as u32
159                }
160            } else {
161                0
162            };
163
164            let utc_dt = DateTime::from_timestamp(secs, subsec_nano)
165                .ok_or_else(|| an_err!(DtErrKind::InvalidNumber, "timestamp: {:?}", secs))?;
166            let offset = FixedOffset::east_opt(0)
167                .ok_or_else(|| an_err!(DtErrKind::InvalidTimezoneOffset))?;
168            return Ok(utc_dt.with_timezone(&offset));
169        }
170
171        // ============================================================
172        // CIVIL TIME PATH (local time in the given zone)
173        // ============================================================
174        let naive = self.to_chrono_naive_datetime()?;
175
176        if let Some(name) = &self.iana_name {
177            let name_str = name.as_str().map_err(|e| {
178                an_err!(
179                    DtErrKind::InvalidBytes,
180                    "invalid iana ascii: {:?}: {}",
181                    name,
182                    e
183                )
184            })?;
185            if !name_str.is_empty() {
186                #[cfg(feature = "chrono-tz")]
187                {
188                    let tz: Tz = name_str.parse().map_err(|e| {
189                        an_err!(
190                            DtErrKind::InvalidTimezoneOffset,
191                            "unknown IANA '{}': {}",
192                            name_str,
193                            e
194                        )
195                    })?;
196                    let local = tz.from_local_datetime(&naive);
197                    let zoned = local.single().ok_or_else(|| {
198                        an_err!(
199                            DtErrKind::InvalidTimezoneOffset,
200                            "local time is ambiguous or invalid in timezone '{}'",
201                            name_str
202                        )
203                    })?;
204                    return Ok(zoned.fixed_offset());
205                }
206
207                #[cfg(not(feature = "chrono-tz"))]
208                {
209                    let provisional_unix =
210                        DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)
211                            .timestamp();
212
213                    match crate::tzdb::offset_info_at_local(name_str, provisional_unix) {
214                        Some(info) => {
215                            let mut local_naive = naive;
216
217                            if info.is_gap {
218                                // Non-existent time (spring-forward gap) — shift forward
219                                local_naive += chrono::Duration::seconds(info.gap_size as i64);
220                            }
221
222                            let offset = FixedOffset::east_opt(info.offset).ok_or_else(|| {
223                                an_err!(DtErrKind::InvalidTimezoneOffset, "offset: {}", info.offset)
224                            })?;
225
226                            return offset
227                                .from_local_datetime(&local_naive)
228                                .single()
229                                .ok_or_else(|| {
230                                    an_err!(
231                                        DtErrKind::InvalidTimezoneOffset,
232                                        "offset: {:?}",
233                                        offset
234                                    )
235                                });
236                        }
237                        None => {
238                            return Err(an_err!(
239                                DtErrKind::InvalidTimezoneOffset,
240                                "invalid iana: {}",
241                                name_str
242                            ));
243                        }
244                    }
245                }
246            }
247        }
248
249        // Fixed-offset civil path
250        let offset = self.to_chrono_offset()?;
251        offset
252            .from_local_datetime(&naive)
253            .single()
254            .ok_or_else(|| an_err!(DtErrKind::InvalidTimezoneOffset, "offset: {:?}", offset))
255    }
256
257    /// Converts [`TimeParts`] → [`i64`].
258    /// - If this [`TimeParts`] has a unix timestamp then it is used
259    ///   instead of anything else, timezones are ignored in this route.
260    /// - If the `"chrono-tz"` feature is enabled then chronos tz features
261    ///   are used to parse the IANA name.
262    /// - If the `"chrono-tz"` feature is **not** enabled then the library's
263    ///   own tz handling will be used.
264    /// - Uses [`TimeParts::to_chrono_datetime`] internally.
265    pub fn to_chrono_timestamp(&self) -> Result<i64, DtErr> {
266        if let Some(secs) = self.unix_timestamp_seconds {
267            return Ok(secs);
268        }
269        let dt = self.to_chrono_datetime()?;
270        Ok(dt.timestamp())
271    }
272}