aimcal_core/
datetime.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use chrono::offset::LocalResult;
6use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
7use chrono_tz::Tz;
8use icalendar::{CalendarDateTime, DatePerhapsTime};
9
10/// A date and time that may be in different formats, such as date only, floating time, or local time with timezone.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum LooseDateTime {
13    /// Date only without time.
14    DateOnly(NaiveDate),
15
16    /// Floating date and time without timezone.
17    Floating(NaiveDateTime),
18
19    /// Local date and time with timezone.
20    /// NOTE: This is always in the local timezone of the system running the code.
21    Local(DateTime<Local>),
22}
23
24impl LooseDateTime {
25    /// Returns the date part
26    pub fn date(&self) -> NaiveDate {
27        match self {
28            LooseDateTime::DateOnly(d) => *d,
29            LooseDateTime::Floating(dt) => dt.date(),
30            LooseDateTime::Local(dt) => dt.date_naive(),
31        }
32    }
33
34    /// Returns the time part, if available.
35    pub fn time(&self) -> Option<NaiveTime> {
36        match self {
37            LooseDateTime::DateOnly(_) => None,
38            LooseDateTime::Floating(dt) => Some(dt.time()),
39            LooseDateTime::Local(dt) => Some(dt.time()),
40        }
41    }
42
43    /// Converts to a datetime with default start time (00:00:00) if time is missing.
44    pub fn with_start_of_day(&self) -> NaiveDateTime {
45        NaiveDateTime::new(
46            self.date(),
47            self.time()
48                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
49        )
50    }
51
52    /// Converts to a datetime with default end time (23:59:59.999999999) if time is missing.
53    pub fn with_end_of_day(&self) -> NaiveDateTime {
54        NaiveDateTime::new(
55            self.date(),
56            self.time().unwrap_or_else(|| {
57                // Using a leap second to represent the end of the day
58                NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
59            }),
60        )
61    }
62
63    /// Determines the position of a given datetime relative to a start and optional end date.
64    pub fn position_in_range(
65        t: &NaiveDateTime,
66        start: &LooseDateTime,
67        end: &Option<LooseDateTime>,
68    ) -> RangePosition {
69        let start_dt = start.with_start_of_day(); // 00:00
70        match end {
71            Some(end) => {
72                let end_dt = end.with_end_of_day(); // 23:59
73                if start_dt > end_dt {
74                    RangePosition::InvalidRange
75                } else if t > &end_dt {
76                    RangePosition::After
77                } else if t <= &start_dt {
78                    RangePosition::Before
79                } else {
80                    RangePosition::InRange
81                }
82            }
83            None => match &start_dt <= t {
84                true => RangePosition::InRange,
85                false => RangePosition::Before,
86            },
87        }
88    }
89
90    /// NOTE: Used for storing in the database, so it should be stable across different runs.
91    const DATEONLY_FORMAT: &str = "%Y-%m-%d";
92    const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
93    const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
94
95    /// Converts to a string representation of date and time.
96    pub(crate) fn format_stable(&self) -> String {
97        match self {
98            LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
99            LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
100            LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
101        }
102    }
103
104    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
105        match s.len() {
106            // 2006-01-02
107            10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
108                .map(Self::DateOnly)
109                .ok(),
110
111            // 2006-01-02T15:04:05
112            19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
113                .map(Self::Floating)
114                .ok(),
115
116            // 2006-01-02T15:04:05Z
117            20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
118                .map(|a| Self::Local(a.with_timezone(&Local)))
119                .ok(),
120
121            _ => None,
122        }
123    }
124}
125
126impl From<DatePerhapsTime> for LooseDateTime {
127    fn from(dt: DatePerhapsTime) -> Self {
128        match dt {
129            DatePerhapsTime::DateTime(dt) => match dt {
130                CalendarDateTime::Floating(dt) => LooseDateTime::Floating(dt),
131                CalendarDateTime::Utc(dt) => LooseDateTime::Local(dt.into()),
132                CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
133                    Ok(tz) => match tz.from_local_datetime(&date_time) {
134                        // Use the parsed timezone to interpret the datetime
135                        LocalResult::Single(dt_in_tz) => {
136                            LooseDateTime::Local(dt_in_tz.with_timezone(&Local))
137                        }
138                        LocalResult::Ambiguous(dt1, _) => {
139                            log::warn!(
140                                "Ambiguous local time for {date_time} in {tzid}, picking earliest"
141                            );
142                            LooseDateTime::Local(dt1.with_timezone(&Local))
143                        }
144                        LocalResult::None => {
145                            log::warn!(
146                                "Invalid local time for {date_time} in {tzid}, falling back to floating"
147                            );
148                            LooseDateTime::Floating(date_time)
149                        }
150                    },
151                    _ => {
152                        log::warn!("Unknown timezone, treating as floating: {tzid}");
153                        LooseDateTime::Floating(date_time)
154                    }
155                },
156            },
157            DatePerhapsTime::Date(d) => LooseDateTime::DateOnly(d),
158        }
159    }
160}
161
162impl From<LooseDateTime> for DatePerhapsTime {
163    fn from(dt: LooseDateTime) -> Self {
164        use DatePerhapsTime::*;
165        match dt {
166            LooseDateTime::DateOnly(d) => Date(d),
167            LooseDateTime::Floating(dt) => DateTime(CalendarDateTime::Floating(dt)),
168            LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
169                Ok(tzid) => DateTime(CalendarDateTime::WithTimezone {
170                    date_time: dt.naive_local(),
171                    tzid,
172                }),
173                Err(_) => DateTime(CalendarDateTime::Utc(dt.into())),
174            },
175        }
176    }
177}
178
179impl From<NaiveDate> for LooseDateTime {
180    fn from(d: NaiveDate) -> Self {
181        LooseDateTime::DateOnly(d)
182    }
183}
184
185impl From<NaiveDateTime> for LooseDateTime {
186    fn from(dt: NaiveDateTime) -> Self {
187        LooseDateTime::Floating(dt)
188    }
189}
190
191impl From<DateTime<Local>> for LooseDateTime {
192    fn from(dt: DateTime<Local>) -> Self {
193        LooseDateTime::Local(dt)
194    }
195}
196
197impl From<DateTime<Utc>> for LooseDateTime {
198    fn from(dt: DateTime<Utc>) -> Self {
199        LooseDateTime::Local(dt.with_timezone(&Local))
200    }
201}
202
203/// The position of a date relative to a range defined by a start and optional end date.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum RangePosition {
206    /// The date is before the start of the range.
207    Before,
208
209    /// The date is within the range.
210    InRange,
211
212    /// The date is after the start of the range.
213    After,
214
215    /// The range is invalid, e.g., start date is after end date.
216    InvalidRange,
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use chrono::{NaiveDate, TimeZone};
223
224    #[test]
225    fn test_date_and_time_methods() {
226        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
227        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
228        let datetime = NaiveDateTime::new(date, time);
229        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
230
231        let d1 = LooseDateTime::DateOnly(date);
232        let d2 = LooseDateTime::Floating(datetime);
233        let d3 = LooseDateTime::Local(local_dt);
234
235        // Date
236        assert_eq!(d1.date(), date);
237        assert_eq!(d2.date(), date);
238        assert_eq!(d3.date(), date);
239
240        // Time
241        assert_eq!(d1.time(), None);
242        assert_eq!(d2.time(), Some(time));
243        assert_eq!(d3.time(), Some(time));
244    }
245
246    #[test]
247    fn test_with_start_of_day() {
248        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
249        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
250        let datetime = NaiveDateTime::new(date, time);
251        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
252
253        let d1 = LooseDateTime::DateOnly(date);
254        let d2 = LooseDateTime::Floating(datetime);
255        let d3 = LooseDateTime::Local(local_dt);
256
257        assert_eq!(
258            d1.with_start_of_day(),
259            NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
260        );
261        assert_eq!(d2.with_start_of_day(), datetime);
262        assert_eq!(d3.with_start_of_day(), datetime);
263    }
264
265    #[test]
266    fn test_with_end_of_day() {
267        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
268        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
269        let datetime = NaiveDateTime::new(date, time);
270        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
271
272        let d1 = LooseDateTime::DateOnly(date);
273        let d2 = LooseDateTime::Floating(datetime);
274        let d3 = LooseDateTime::Local(local_dt);
275
276        assert_eq!(
277            d1.with_end_of_day(),
278            NaiveDateTime::new(
279                date,
280                NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
281            )
282        );
283        assert_eq!(d2.with_end_of_day(), datetime);
284        assert_eq!(d3.with_end_of_day(), datetime);
285    }
286
287    #[test]
288    fn test_position_in_range_with_end() {
289        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
290        let end = Some(LooseDateTime::DateOnly(
291            NaiveDate::from_ymd_opt(2024, 1, 3).unwrap(),
292        ));
293
294        let t_before = NaiveDate::from_ymd_opt(2023, 12, 31)
295            .unwrap()
296            .and_hms_opt(23, 59, 59)
297            .unwrap();
298        let t_in = NaiveDate::from_ymd_opt(2024, 1, 2)
299            .unwrap()
300            .and_hms_opt(12, 0, 0)
301            .unwrap();
302        let t_after = NaiveDate::from_ymd_opt(2024, 1, 4)
303            .unwrap()
304            .and_hms_opt(0, 0, 0)
305            .unwrap();
306
307        assert_eq!(
308            LooseDateTime::position_in_range(&t_before, &start, &end),
309            RangePosition::Before
310        );
311        assert_eq!(
312            LooseDateTime::position_in_range(&t_in, &start, &end),
313            RangePosition::InRange
314        );
315        assert_eq!(
316            LooseDateTime::position_in_range(&t_after, &start, &end),
317            RangePosition::After
318        );
319    }
320
321    #[test]
322    fn test_position_in_range_without_end() {
323        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
324
325        let t1 = NaiveDate::from_ymd_opt(2023, 12, 31)
326            .unwrap()
327            .and_hms_opt(23, 59, 59)
328            .unwrap();
329        let t2 = NaiveDate::from_ymd_opt(2024, 1, 1)
330            .unwrap()
331            .and_hms_opt(0, 0, 0)
332            .unwrap();
333
334        assert_eq!(
335            LooseDateTime::position_in_range(&t1, &start, &None),
336            RangePosition::Before
337        );
338        assert_eq!(
339            LooseDateTime::position_in_range(&t2, &start, &None),
340            RangePosition::InRange
341        );
342    }
343
344    #[test]
345    fn test_invalid_range() {
346        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
347        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
348        let t = NaiveDate::from_ymd_opt(2024, 1, 3)
349            .unwrap()
350            .and_hms_opt(12, 0, 0)
351            .unwrap();
352
353        assert_eq!(
354            LooseDateTime::position_in_range(&t, &start, &Some(end)),
355            RangePosition::InvalidRange
356        );
357    }
358
359    #[test]
360    fn test_format_and_parse_stable() {
361        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
362        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
363        let datetime = NaiveDateTime::new(date, time);
364        let local = TimeZone::with_ymd_and_hms(&Local, 2024, 7, 18, 12, 30, 45).unwrap();
365
366        let d1 = LooseDateTime::DateOnly(date);
367        let d2 = LooseDateTime::Floating(datetime);
368        let d3 = LooseDateTime::Local(local);
369
370        // Format
371        let f1 = d1.format_stable();
372        let f2 = d2.format_stable();
373        let f3 = d3.format_stable();
374
375        assert_eq!(f1, "2024-07-18");
376        assert_eq!(f2, "2024-07-18T12:30:45");
377        assert!(f3.starts_with("2024-07-18T12:30:45"));
378
379        // Parse
380        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
381        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
382        let parsed3 = LooseDateTime::parse_stable(&f3);
383        if let Some(LooseDateTime::Local(dt)) = parsed3 {
384            assert_eq!(dt.naive_local(), local.naive_local());
385        } else {
386            panic!("Failed to parse local datetime");
387        }
388    }
389}