Skip to main content

rosetta_date/
datetime.rs

1//! Unified date-time representation that abstracts over `time` and `chrono` backends.
2
3use crate::error::{Result, RosettaError};
4use crate::timezone::TzOffset;
5
6/// A unified date-time type that wraps either `time::OffsetDateTime` or
7/// `chrono::DateTime<FixedOffset>` depending on the enabled backend feature.
8#[derive(Debug, Clone)]
9pub enum RosettaDateTime {
10    #[cfg(feature = "time-backend")]
11    Time(time::OffsetDateTime),
12
13    #[cfg(feature = "chrono-backend")]
14    Chrono(chrono::DateTime<chrono::FixedOffset>),
15}
16
17impl PartialEq for RosettaDateTime {
18    fn eq(&self, other: &Self) -> bool {
19        self.timestamp() == other.timestamp()
20    }
21}
22impl Eq for RosettaDateTime {}
23
24impl PartialOrd for RosettaDateTime {
25    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
26        Some(self.cmp(other))
27    }
28}
29impl Ord for RosettaDateTime {
30    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
31        self.timestamp().cmp(&other.timestamp())
32    }
33}
34
35// ── Constructors ──────────────────────────────────────────────────────
36
37impl RosettaDateTime {
38    /// Create a `RosettaDateTime` from individual components.
39    pub fn from_components(
40        year: i32,
41        month: u8,
42        day: u8,
43        hour: u8,
44        minute: u8,
45        second: u8,
46        offset: TzOffset,
47    ) -> Result<Self> {
48        #[cfg(feature = "time-backend")]
49        {
50            let date = time::Date::from_calendar_date(
51                year,
52                time::Month::try_from(month)
53                    .map_err(|e| RosettaError::ParseError(e.to_string()))?,
54                day,
55            )
56            .map_err(|e| RosettaError::ParseError(e.to_string()))?;
57
58            let time_val = time::Time::from_hms(hour, minute, second)
59                .map_err(|e| RosettaError::ParseError(e.to_string()))?;
60
61            let tz = time::UtcOffset::from_whole_seconds(offset.total_seconds)
62                .map_err(|e| RosettaError::TimezoneError(e.to_string()))?;
63
64            Ok(Self::Time(
65                time::PrimitiveDateTime::new(date, time_val).assume_offset(tz),
66            ))
67        }
68
69        #[cfg(all(feature = "chrono-backend", not(feature = "time-backend")))]
70        {
71            use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
72            let nd = NaiveDate::from_ymd_opt(year, month as u32, day as u32)
73                .ok_or_else(|| RosettaError::ParseError("Invalid date".into()))?;
74            let nt = NaiveTime::from_hms_opt(hour as u32, minute as u32, second as u32)
75                .ok_or_else(|| RosettaError::ParseError("Invalid time".into()))?;
76            let ndt = NaiveDateTime::new(nd, nt);
77            let fo = FixedOffset::east_opt(offset.total_seconds)
78                .ok_or_else(|| RosettaError::TimezoneError("Invalid offset".into()))?;
79            Ok(Self::Chrono(
80                DateTime::<FixedOffset>::from_naive_utc_and_offset(ndt - fo, fo),
81            ))
82        }
83
84        #[cfg(not(any(feature = "time-backend", feature = "chrono-backend")))]
85        {
86            let _ = (year, month, day, hour, minute, second, offset);
87            Err(RosettaError::ParseError(
88                "No backend feature enabled".into(),
89            ))
90        }
91    }
92
93    /// Get the current time in UTC.
94    pub fn now_utc() -> Self {
95        #[cfg(feature = "time-backend")]
96        {
97            Self::Time(time::OffsetDateTime::now_utc())
98        }
99
100        #[cfg(all(feature = "chrono-backend", not(feature = "time-backend")))]
101        {
102            Self::Chrono(chrono::Utc::now().fixed_offset())
103        }
104
105        #[cfg(not(any(feature = "time-backend", feature = "chrono-backend")))]
106        {
107            panic!("No backend feature enabled")
108        }
109    }
110}
111
112// ── Accessors ─────────────────────────────────────────────────────────
113
114impl RosettaDateTime {
115    /// Unix timestamp in seconds.
116    pub fn timestamp(&self) -> i64 {
117        match self {
118            #[cfg(feature = "time-backend")]
119            Self::Time(dt) => dt.unix_timestamp(),
120            #[cfg(feature = "chrono-backend")]
121            Self::Chrono(dt) => dt.timestamp(),
122        }
123    }
124
125    pub fn year(&self) -> i32 {
126        match self {
127            #[cfg(feature = "time-backend")]
128            Self::Time(dt) => dt.year(),
129            #[cfg(feature = "chrono-backend")]
130            Self::Chrono(dt) => chrono::Datelike::year(dt),
131        }
132    }
133
134    /// 1-indexed month (1 = January).
135    pub fn month(&self) -> u8 {
136        match self {
137            #[cfg(feature = "time-backend")]
138            Self::Time(dt) => dt.month() as u8,
139            #[cfg(feature = "chrono-backend")]
140            Self::Chrono(dt) => chrono::Datelike::month(dt) as u8,
141        }
142    }
143
144    /// Day of the month (1–31).
145    pub fn day(&self) -> u8 {
146        match self {
147            #[cfg(feature = "time-backend")]
148            Self::Time(dt) => dt.day(),
149            #[cfg(feature = "chrono-backend")]
150            Self::Chrono(dt) => chrono::Datelike::day(dt) as u8,
151        }
152    }
153
154    pub fn hour(&self) -> u8 {
155        match self {
156            #[cfg(feature = "time-backend")]
157            Self::Time(dt) => dt.hour(),
158            #[cfg(feature = "chrono-backend")]
159            Self::Chrono(dt) => chrono::Timelike::hour(dt) as u8,
160        }
161    }
162
163    pub fn minute(&self) -> u8 {
164        match self {
165            #[cfg(feature = "time-backend")]
166            Self::Time(dt) => dt.minute(),
167            #[cfg(feature = "chrono-backend")]
168            Self::Chrono(dt) => chrono::Timelike::minute(dt) as u8,
169        }
170    }
171
172    pub fn second(&self) -> u8 {
173        match self {
174            #[cfg(feature = "time-backend")]
175            Self::Time(dt) => dt.second(),
176            #[cfg(feature = "chrono-backend")]
177            Self::Chrono(dt) => chrono::Timelike::second(dt) as u8,
178        }
179    }
180
181    /// Day of the week (0 = Monday, 6 = Sunday).
182    pub fn weekday(&self) -> u8 {
183        match self {
184            #[cfg(feature = "time-backend")]
185            Self::Time(dt) => dt.weekday().number_days_from_monday(),
186            #[cfg(feature = "chrono-backend")]
187            Self::Chrono(dt) => chrono::Datelike::weekday(dt).num_days_from_monday() as u8,
188        }
189    }
190
191    /// UTC offset in total seconds.
192    pub fn offset_seconds(&self) -> i32 {
193        match self {
194            #[cfg(feature = "time-backend")]
195            Self::Time(dt) => dt.offset().whole_seconds(),
196            #[cfg(feature = "chrono-backend")]
197            Self::Chrono(dt) => {
198                use chrono::Offset;
199                dt.offset().fix().local_minus_utc()
200            }
201        }
202    }
203
204    pub fn offset(&self) -> TzOffset {
205        TzOffset {
206            total_seconds: self.offset_seconds(),
207        }
208    }
209}
210
211// ── Arithmetic ────────────────────────────────────────────────────────
212
213impl RosettaDateTime {
214    /// Add (or subtract if negative) a number of seconds.
215    pub fn add_seconds(self, secs: i64) -> Self {
216        match self {
217            #[cfg(feature = "time-backend")]
218            Self::Time(dt) => Self::Time(dt + time::Duration::seconds(secs)),
219            #[cfg(feature = "chrono-backend")]
220            Self::Chrono(dt) => Self::Chrono(dt + chrono::Duration::seconds(secs)),
221        }
222    }
223
224    /// Add minutes.
225    pub fn add_minutes(self, mins: i64) -> Self {
226        self.add_seconds(mins * 60)
227    }
228
229    /// Add hours.
230    pub fn add_hours(self, hrs: i64) -> Self {
231        self.add_seconds(hrs * 3600)
232    }
233
234    /// Add days.
235    pub fn add_days(self, days: i64) -> Self {
236        self.add_seconds(days * 86400)
237    }
238
239    /// Add weeks.
240    pub fn add_weeks(self, weeks: i64) -> Self {
241        self.add_days(weeks * 7)
242    }
243
244    /// Approximate month addition (adds 30 days per month).
245    pub fn add_months_approx(self, months: i64) -> Self {
246        self.add_days(months * 30)
247    }
248
249    /// Approximate year addition (adds 365 days per year).
250    pub fn add_years_approx(self, years: i64) -> Self {
251        self.add_days(years * 365)
252    }
253
254    /// Convert to a different timezone offset.
255    pub fn to_offset(self, new_offset: TzOffset) -> Result<Self> {
256        match self {
257            #[cfg(feature = "time-backend")]
258            Self::Time(dt) => {
259                let tz = time::UtcOffset::from_whole_seconds(new_offset.total_seconds)
260                    .map_err(|e| RosettaError::TimezoneError(e.to_string()))?;
261                Ok(Self::Time(dt.to_offset(tz)))
262            }
263            #[cfg(feature = "chrono-backend")]
264            Self::Chrono(dt) => {
265                let fo = chrono::FixedOffset::east_opt(new_offset.total_seconds)
266                    .ok_or_else(|| RosettaError::TimezoneError("Invalid offset".into()))?;
267                Ok(Self::Chrono(dt.with_timezone(&fo)))
268            }
269        }
270    }
271}
272
273// ── Display ───────────────────────────────────────────────────────────
274
275impl std::fmt::Display for RosettaDateTime {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        // ISO 8601 representation
278        write!(
279            f,
280            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}",
281            self.year(),
282            self.month(),
283            self.day(),
284            self.hour(),
285            self.minute(),
286            self.second(),
287            self.offset(),
288        )
289    }
290}