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};
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: &Option<LooseDateTime>,
67        end: &Option<LooseDateTime>,
68    ) -> RangePosition {
69        match (start, end) {
70            (Some(start), Some(end)) => {
71                let start_dt = start.with_start_of_day(); // 00:00
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            (Some(start), None) => match t >= &start.with_start_of_day() {
84                true => RangePosition::InRange,
85                false => RangePosition::Before,
86            },
87            (None, Some(end)) => match t > &end.with_end_of_day() {
88                true => RangePosition::After,
89                false => RangePosition::InRange,
90            },
91            (None, None) => RangePosition::InvalidRange,
92        }
93    }
94
95    /// NOTE: Used for storing in the database, so it should be stable across different runs.
96    const DATEONLY_FORMAT: &str = "%Y-%m-%d";
97    const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
98    const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
99
100    /// Converts to a string representation of date and time.
101    pub(crate) fn format_stable(&self) -> String {
102        match self {
103            LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
104            LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
105            LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
106        }
107    }
108
109    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
110        match s.len() {
111            // 2006-01-02
112            10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
113                .map(Self::DateOnly)
114                .ok(),
115
116            // 2006-01-02T15:04:05
117            19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
118                .map(Self::Floating)
119                .ok(),
120
121            // 2006-01-02T15:04:05Z
122            20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
123                .map(|a| Self::Local(a.with_timezone(&Local)))
124                .ok(),
125
126            _ => None,
127        }
128    }
129}
130
131impl From<DatePerhapsTime> for LooseDateTime {
132    #[tracing::instrument]
133    fn from(dt: DatePerhapsTime) -> Self {
134        match dt {
135            DatePerhapsTime::DateTime(dt) => match dt {
136                CalendarDateTime::Floating(dt) => dt.into(),
137                CalendarDateTime::Utc(dt) => dt.into(),
138                CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
139                    Ok(tz) => match tz.from_local_datetime(&date_time) {
140                        // Use the parsed timezone to interpret the datetime
141                        LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
142                        LocalResult::Ambiguous(dt1, _) => {
143                            tracing::warn!(tzid, "ambiguous local time, picking earliest");
144                            dt1.into()
145                        }
146                        LocalResult::None => {
147                            tracing::warn!(tzid, "invalid local time, falling back to floating");
148                            date_time.into()
149                        }
150                    },
151                    Err(_) => {
152                        tracing::warn!(tzid, "unknown timezone, treating as floating");
153                        date_time.into()
154                    }
155                },
156            },
157            DatePerhapsTime::Date(d) => d.into(),
158        }
159    }
160}
161
162impl From<LooseDateTime> for DatePerhapsTime {
163    fn from(dt: LooseDateTime) -> Self {
164        match dt {
165            LooseDateTime::DateOnly(d) => d.into(),
166            LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
167            LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
168                Ok(tzid) => CalendarDateTime::WithTimezone {
169                    date_time: dt.naive_local(),
170                    tzid,
171                }
172                .into(),
173                Err(_) => {
174                    tracing::warn!("Failed to get timezone, using UTC");
175                    CalendarDateTime::Utc(dt.into()).into()
176                }
177            },
178        }
179    }
180}
181
182impl From<NaiveDate> for LooseDateTime {
183    fn from(d: NaiveDate) -> Self {
184        LooseDateTime::DateOnly(d)
185    }
186}
187
188impl From<NaiveDateTime> for LooseDateTime {
189    fn from(dt: NaiveDateTime) -> Self {
190        LooseDateTime::Floating(dt)
191    }
192}
193
194impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
195    fn from(dt: DateTime<Tz>) -> Self {
196        LooseDateTime::Local(dt.with_timezone(&Local))
197    }
198}
199
200/// The position of a date relative to a range defined by a start and optional end date.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum RangePosition {
203    /// The date is before the start of the range.
204    Before,
205
206    /// The date is within the range.
207    InRange,
208
209    /// The date is after the start of the range.
210    After,
211
212    /// The range is invalid, e.g., start date is after end date.
213    InvalidRange,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use chrono::{NaiveDate, TimeZone};
220
221    #[test]
222    fn test_date_and_time_methods() {
223        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
224        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
225        let datetime = NaiveDateTime::new(date, time);
226        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
227
228        let d1 = LooseDateTime::DateOnly(date);
229        let d2 = LooseDateTime::Floating(datetime);
230        let d3 = LooseDateTime::Local(local_dt);
231
232        // Date
233        assert_eq!(d1.date(), date);
234        assert_eq!(d2.date(), date);
235        assert_eq!(d3.date(), date);
236
237        // Time
238        assert_eq!(d1.time(), None);
239        assert_eq!(d2.time(), Some(time));
240        assert_eq!(d3.time(), Some(time));
241    }
242
243    #[test]
244    fn test_with_start_of_day() {
245        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
246        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
247        let datetime = NaiveDateTime::new(date, time);
248        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
249
250        let d1 = LooseDateTime::DateOnly(date);
251        let d2 = LooseDateTime::Floating(datetime);
252        let d3 = LooseDateTime::Local(local_dt);
253
254        assert_eq!(
255            d1.with_start_of_day(),
256            NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
257        );
258        assert_eq!(d2.with_start_of_day(), datetime);
259        assert_eq!(d3.with_start_of_day(), datetime);
260    }
261
262    #[test]
263    fn test_with_end_of_day() {
264        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
265        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
266        let datetime = NaiveDateTime::new(date, time);
267        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
268
269        let d1 = LooseDateTime::DateOnly(date);
270        let d2 = LooseDateTime::Floating(datetime);
271        let d3 = LooseDateTime::Local(local_dt);
272
273        assert_eq!(
274            d1.with_end_of_day(),
275            NaiveDateTime::new(
276                date,
277                NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
278            )
279        );
280        assert_eq!(d2.with_end_of_day(), datetime);
281        assert_eq!(d3.with_end_of_day(), datetime);
282    }
283
284    fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
285        NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
286    }
287
288    #[test]
289    fn test_position_in_range_date_date() {
290        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
291        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
292
293        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
294        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
295        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
296        let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
297
298        assert_eq!(
299            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
300            RangePosition::Before
301        );
302        assert_eq!(
303            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
304            RangePosition::InRange
305        );
306        assert_eq!(
307            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
308            RangePosition::InRange
309        );
310        assert_eq!(
311            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
312            RangePosition::After
313        );
314    }
315
316    #[test]
317    fn test_position_in_range_date_floating() {
318        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
319        let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
320
321        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
322        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
323        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
324        let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
325
326        assert_eq!(
327            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
328            RangePosition::Before
329        );
330        assert_eq!(
331            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
332            RangePosition::InRange
333        );
334        assert_eq!(
335            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
336            RangePosition::InRange
337        );
338        assert_eq!(
339            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
340            RangePosition::After
341        );
342    }
343
344    #[test]
345    fn test_position_in_range_floating_date() {
346        let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
347        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
348
349        let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
350        let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
351        let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
352        let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
353
354        assert_eq!(
355            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
356            RangePosition::Before
357        );
358        assert_eq!(
359            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
360            RangePosition::InRange
361        );
362        assert_eq!(
363            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
364            RangePosition::InRange
365        );
366        assert_eq!(
367            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
368            RangePosition::After
369        );
370    }
371
372    #[test]
373    fn test_position_in_range_without_start() {
374        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
375        let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
376
377        for end in [
378            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
379            LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
380            LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
381        ] {
382            assert_eq!(
383                LooseDateTime::position_in_range(&t1, &None, &Some(end)),
384                RangePosition::InRange,
385                "end = {end:?}"
386            );
387            assert_eq!(
388                LooseDateTime::position_in_range(&t2, &None, &Some(end)),
389                RangePosition::After,
390                "end = {end:?}"
391            );
392        }
393    }
394
395    #[test]
396    fn test_position_in_range_date_without_end() {
397        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
398        let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
399
400        for start in [
401            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
402            LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
403            LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
404        ] {
405            assert_eq!(
406                LooseDateTime::position_in_range(&t1, &Some(start), &None),
407                RangePosition::Before,
408                "start = {start:?}"
409            );
410            assert_eq!(
411                LooseDateTime::position_in_range(&t2, &Some(start), &None),
412                RangePosition::InRange,
413                "start = {start:?}"
414            );
415        }
416    }
417
418    #[test]
419    fn test_invalid_range() {
420        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
421        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
422
423        let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
424
425        assert_eq!(
426            LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
427            RangePosition::InvalidRange
428        );
429
430        assert_eq!(
431            LooseDateTime::position_in_range(&t, &None, &None),
432            RangePosition::InvalidRange
433        );
434    }
435
436    #[test]
437    fn test_format_and_parse_stable() {
438        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
439        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
440        let datetime = NaiveDateTime::new(date, time);
441        let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
442
443        let d1 = LooseDateTime::DateOnly(date);
444        let d2 = LooseDateTime::Floating(datetime);
445        let d3 = LooseDateTime::Local(local);
446
447        // Format
448        let f1 = d1.format_stable();
449        let f2 = d2.format_stable();
450        let f3 = d3.format_stable();
451
452        assert_eq!(f1, "2024-07-18");
453        assert_eq!(f2, "2024-07-18T12:30:45");
454        assert!(f3.starts_with("2024-07-18T12:30:45"));
455
456        // Parse
457        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
458        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
459        let parsed3 = LooseDateTime::parse_stable(&f3);
460        if let Some(LooseDateTime::Local(dt)) = parsed3 {
461            assert_eq!(dt.naive_local(), local.naive_local());
462        } else {
463            panic!("Failed to parse local datetime");
464        }
465    }
466}