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