aimcal_core/
datetime.rs

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