aimcal_core/datetime/
loose.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::ops::Add;
6
7use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, offset::LocalResult};
8use chrono_tz::Tz;
9use icalendar::{CalendarDateTime, DatePerhapsTime};
10
11use crate::RangePosition;
12use crate::datetime::util::{
13    STABLE_FORMAT_DATEONLY, STABLE_FORMAT_FLOATING, STABLE_FORMAT_LOCAL, end_of_day_naive,
14    start_of_day_naive,
15};
16
17/// A date and time that may be in different formats, such as date only, floating time, or local time with timezone.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LooseDateTime {
20    /// Date only without time.
21    DateOnly(NaiveDate),
22
23    /// Floating date and time without timezone.
24    Floating(NaiveDateTime),
25
26    /// Local date and time with timezone.
27    /// NOTE: This is always in the local timezone of the system running the code.
28    Local(DateTime<Local>),
29}
30
31impl LooseDateTime {
32    /// The date part.
33    pub fn date(&self) -> NaiveDate {
34        match self {
35            LooseDateTime::DateOnly(d) => *d,
36            LooseDateTime::Floating(dt) => dt.date(),
37            LooseDateTime::Local(dt) => dt.date_naive(),
38        }
39    }
40
41    /// The time part, if available.
42    pub fn time(&self) -> Option<NaiveTime> {
43        match self {
44            LooseDateTime::DateOnly(_) => None,
45            LooseDateTime::Floating(dt) => Some(dt.time()),
46            LooseDateTime::Local(dt) => Some(dt.time()),
47        }
48    }
49
50    /// Converts to a datetime with default start time (00:00:00) if time is missing.
51    pub fn with_start_of_day(&self) -> NaiveDateTime {
52        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(start_of_day_naive))
53    }
54
55    /// Converts to a datetime with default end time (23:59:59.999999999) if time is missing.
56    pub fn with_end_of_day(&self) -> NaiveDateTime {
57        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(end_of_day_naive))
58    }
59
60    /// Determines the position of a given datetime relative to a start and optional end date.
61    pub fn position_in_range(
62        t: &NaiveDateTime,
63        start: &Option<LooseDateTime>,
64        end: &Option<LooseDateTime>,
65    ) -> RangePosition {
66        match (start, end) {
67            (Some(start), Some(end)) => {
68                let start_dt = start.with_start_of_day(); // 00:00
69                let end_dt = end.with_end_of_day(); // 23:59
70                if start_dt > end_dt {
71                    RangePosition::InvalidRange
72                } else if t > &end_dt {
73                    RangePosition::After
74                } else if t < &start_dt {
75                    RangePosition::Before
76                } else {
77                    RangePosition::InRange
78                }
79            }
80            (Some(start), None) => match t >= &start.with_start_of_day() {
81                true => RangePosition::InRange,
82                false => RangePosition::Before,
83            },
84            (None, Some(end)) => match t > &end.with_end_of_day() {
85                true => RangePosition::After,
86                false => RangePosition::InRange,
87            },
88            (None, None) => RangePosition::InvalidRange,
89        }
90    }
91
92    /// Creates a `LooseDateTime` from a `NaiveDateTime` in the local timezone.
93    pub(crate) fn from_local_datetime(dt: NaiveDateTime) -> LooseDateTime {
94        match Local.from_local_datetime(&dt) {
95            LocalResult::Single(dt) => dt.into(),
96            LocalResult::Ambiguous(dt1, _) => {
97                tracing::warn!(?dt, "ambiguous local time in local, picking earliest");
98                dt1.into()
99            }
100            LocalResult::None => {
101                tracing::warn!(?dt, "invalid local time in local, falling back to floating");
102                dt.into()
103            }
104        }
105    }
106
107    /// Converts to a string representation of date and time.
108    pub(crate) fn format_stable(&self) -> String {
109        match self {
110            LooseDateTime::DateOnly(d) => d.format(STABLE_FORMAT_DATEONLY).to_string(),
111            LooseDateTime::Floating(dt) => dt.format(STABLE_FORMAT_FLOATING).to_string(),
112            LooseDateTime::Local(dt) => dt.format(STABLE_FORMAT_LOCAL).to_string(),
113        }
114    }
115
116    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
117        match s.len() {
118            // 2006-01-02
119            10 => NaiveDate::parse_from_str(s, STABLE_FORMAT_DATEONLY)
120                .map(Self::DateOnly)
121                .ok(),
122
123            // 2006-01-02T15:04:05
124            19 => NaiveDateTime::parse_from_str(s, STABLE_FORMAT_FLOATING)
125                .map(Self::Floating)
126                .ok(),
127
128            // 2006-01-02T15:04:05Z
129            20.. => DateTime::parse_from_str(s, STABLE_FORMAT_LOCAL)
130                .map(|a| Self::Local(a.with_timezone(&Local)))
131                .ok(),
132
133            _ => None,
134        }
135    }
136}
137
138impl From<DatePerhapsTime> for LooseDateTime {
139    #[tracing::instrument]
140    fn from(dt: DatePerhapsTime) -> Self {
141        match dt {
142            DatePerhapsTime::DateTime(dt) => match dt {
143                CalendarDateTime::Floating(dt) => dt.into(),
144                CalendarDateTime::Utc(dt) => dt.into(),
145                CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
146                    Ok(tz) => match tz.from_local_datetime(&date_time) {
147                        // Use the parsed timezone to interpret the datetime
148                        LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
149                        LocalResult::Ambiguous(dt1, _) => {
150                            tracing::warn!(tzid, "ambiguous local time, picking earliest");
151                            dt1.into()
152                        }
153                        LocalResult::None => {
154                            tracing::warn!(tzid, "invalid local time, falling back to floating");
155                            date_time.into()
156                        }
157                    },
158                    Err(_) => {
159                        tracing::warn!(tzid, "unknown timezone, treating as floating");
160                        date_time.into()
161                    }
162                },
163            },
164            DatePerhapsTime::Date(d) => d.into(),
165        }
166    }
167}
168
169impl From<LooseDateTime> for DatePerhapsTime {
170    fn from(dt: LooseDateTime) -> Self {
171        match dt {
172            LooseDateTime::DateOnly(d) => d.into(),
173            LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
174            LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
175                Ok(tzid) => CalendarDateTime::WithTimezone {
176                    date_time: dt.naive_local(),
177                    tzid,
178                }
179                .into(),
180                Err(_) => {
181                    tracing::warn!("Failed to get timezone, using UTC");
182                    CalendarDateTime::Utc(dt.into()).into()
183                }
184            },
185        }
186    }
187}
188
189impl From<NaiveDate> for LooseDateTime {
190    fn from(d: NaiveDate) -> Self {
191        LooseDateTime::DateOnly(d)
192    }
193}
194
195impl From<NaiveDateTime> for LooseDateTime {
196    fn from(dt: NaiveDateTime) -> Self {
197        LooseDateTime::Floating(dt)
198    }
199}
200
201impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
202    fn from(dt: DateTime<Tz>) -> Self {
203        LooseDateTime::Local(dt.with_timezone(&Local))
204    }
205}
206
207impl Add<chrono::TimeDelta> for LooseDateTime {
208    type Output = Self;
209
210    fn add(self, rhs: chrono::TimeDelta) -> Self::Output {
211        match self {
212            LooseDateTime::DateOnly(d) => LooseDateTime::DateOnly(d.add(rhs)),
213            LooseDateTime::Floating(dt) => LooseDateTime::Floating(dt.add(rhs)),
214            LooseDateTime::Local(dt) => LooseDateTime::Local(dt.add(rhs)),
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use chrono::TimeDelta;
222
223    use super::*;
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    fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
289        NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
290    }
291
292    #[test]
293    fn test_position_in_range_date_date() {
294        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
295        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
296
297        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
298        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
299        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
300        let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
301
302        assert_eq!(
303            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
304            RangePosition::Before
305        );
306        assert_eq!(
307            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
308            RangePosition::InRange
309        );
310        assert_eq!(
311            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
312            RangePosition::InRange
313        );
314        assert_eq!(
315            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
316            RangePosition::After
317        );
318    }
319
320    #[test]
321    fn test_position_in_range_date_floating() {
322        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
323        let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
324
325        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
326        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
327        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
328        let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
329
330        assert_eq!(
331            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
332            RangePosition::Before
333        );
334        assert_eq!(
335            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
336            RangePosition::InRange
337        );
338        assert_eq!(
339            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
340            RangePosition::InRange
341        );
342        assert_eq!(
343            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
344            RangePosition::After
345        );
346    }
347
348    #[test]
349    fn test_position_in_range_floating_date() {
350        let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
351        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
352
353        let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
354        let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
355        let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
356        let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
357
358        assert_eq!(
359            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
360            RangePosition::Before
361        );
362        assert_eq!(
363            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
364            RangePosition::InRange
365        );
366        assert_eq!(
367            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
368            RangePosition::InRange
369        );
370        assert_eq!(
371            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
372            RangePosition::After
373        );
374    }
375
376    #[test]
377    fn test_position_in_range_without_start() {
378        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
379        let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
380
381        for end in [
382            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
383            LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
384            LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
385        ] {
386            assert_eq!(
387                LooseDateTime::position_in_range(&t1, &None, &Some(end)),
388                RangePosition::InRange,
389                "end = {end:?}"
390            );
391            assert_eq!(
392                LooseDateTime::position_in_range(&t2, &None, &Some(end)),
393                RangePosition::After,
394                "end = {end:?}"
395            );
396        }
397    }
398
399    #[test]
400    fn test_position_in_range_date_without_end() {
401        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
402        let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
403
404        for start in [
405            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
406            LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
407            LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
408        ] {
409            assert_eq!(
410                LooseDateTime::position_in_range(&t1, &Some(start), &None),
411                RangePosition::Before,
412                "start = {start:?}"
413            );
414            assert_eq!(
415                LooseDateTime::position_in_range(&t2, &Some(start), &None),
416                RangePosition::InRange,
417                "start = {start:?}"
418            );
419        }
420    }
421
422    #[test]
423    fn test_invalid_range() {
424        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
425        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
426
427        let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
428
429        assert_eq!(
430            LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
431            RangePosition::InvalidRange
432        );
433
434        assert_eq!(
435            LooseDateTime::position_in_range(&t, &None, &None),
436            RangePosition::InvalidRange
437        );
438    }
439
440    #[test]
441    fn test_from_local_datetime() {
442        // Test with a valid datetime that should produce a single result
443        let datetime = DateTime::from_timestamp(1609459200, 0)
444            .expect("Valid timestamp for 2021-01-01 00:00:00")
445            .naive_local();
446        let loose_dt = LooseDateTime::from_local_datetime(datetime);
447
448        // Should convert to Local variant
449        assert!(matches!(loose_dt, LooseDateTime::Local(_)));
450    }
451
452    #[test]
453    fn test_format_and_parse_stable() {
454        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
455        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
456        let datetime = NaiveDateTime::new(date, time);
457        let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
458
459        let d1 = LooseDateTime::DateOnly(date);
460        let d2 = LooseDateTime::Floating(datetime);
461        let d3 = LooseDateTime::Local(local);
462
463        // Format
464        let f1 = d1.format_stable();
465        let f2 = d2.format_stable();
466        let f3 = d3.format_stable();
467
468        assert_eq!(f1, "2024-07-18");
469        assert_eq!(f2, "2024-07-18T12:30:45");
470        assert!(f3.starts_with("2024-07-18T12:30:45"));
471
472        // Parse
473        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
474        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
475        let parsed3 = LooseDateTime::parse_stable(&f3);
476        if let Some(LooseDateTime::Local(dt)) = parsed3 {
477            assert_eq!(dt.naive_local(), local.naive_local());
478        } else {
479            panic!("Failed to parse local datetime");
480        }
481    }
482
483    #[test]
484    fn test_add_timedelta_dateonly() {
485        let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
486        let added = LooseDateTime::DateOnly(date) + TimeDelta::days(2) + TimeDelta::hours(3);
487        let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 3).unwrap());
488        assert_eq!(added, expected);
489    }
490
491    #[test]
492    fn test_add_timedelta_floating() {
493        let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
494        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
495        let dt = LooseDateTime::Floating(NaiveDateTime::new(date, time));
496        let added = dt + TimeDelta::days(2) + TimeDelta::hours(3);
497        let excepted = LooseDateTime::Floating(NaiveDateTime::new(
498            NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
499            NaiveTime::from_hms_opt(15, 30, 45).unwrap(),
500        ));
501        assert_eq!(added, excepted);
502    }
503
504    #[test]
505    fn test_add_timedelta_local() {
506        let local = Local.with_ymd_and_hms(2025, 1, 1, 12, 30, 45).unwrap();
507        let added = LooseDateTime::Local(local) + TimeDelta::days(2) + TimeDelta::hours(3);
508        let excepted =
509            LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 3, 15, 30, 45).unwrap());
510        assert_eq!(added, excepted);
511    }
512}