aimcal_core/datetime/
loose.rs

1// SPDX-FileCopyrightText: 2025-2026 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    #[must_use]
34    pub fn date(&self) -> NaiveDate {
35        match self {
36            LooseDateTime::DateOnly(d) => *d,
37            LooseDateTime::Floating(dt) => dt.date(),
38            LooseDateTime::Local(dt) => dt.date_naive(),
39        }
40    }
41
42    /// The time part, if available.
43    #[must_use]
44    pub fn time(&self) -> Option<NaiveTime> {
45        match self {
46            LooseDateTime::DateOnly(_) => None,
47            LooseDateTime::Floating(dt) => Some(dt.time()),
48            LooseDateTime::Local(dt) => Some(dt.time()),
49        }
50    }
51
52    /// Converts to a datetime with default start time (00:00:00) if time is missing.
53    pub fn with_start_of_day(&self) -> NaiveDateTime {
54        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(start_of_day_naive))
55    }
56
57    /// Converts to a datetime with default end time (23:59:59.999999999) if time is missing.
58    pub fn with_end_of_day(&self) -> NaiveDateTime {
59        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(end_of_day_naive))
60    }
61
62    /// Determines the position of a given datetime relative to a start and optional end date.
63    #[must_use]
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    /// Creates a `LooseDateTime` from a `NaiveDateTime` in the local timezone.
96    pub(crate) fn from_local_datetime(dt: NaiveDateTime) -> LooseDateTime {
97        match Local.from_local_datetime(&dt) {
98            LocalResult::Single(dt) => dt.into(),
99            LocalResult::Ambiguous(dt1, _) => {
100                tracing::warn!(?dt, "ambiguous local time in local, picking earliest");
101                dt1.into()
102            }
103            LocalResult::None => {
104                tracing::warn!(?dt, "invalid local time in local, falling back to floating");
105                dt.into()
106            }
107        }
108    }
109
110    /// Converts to a string representation of date and time.
111    pub(crate) fn format_stable(&self) -> String {
112        match self {
113            LooseDateTime::DateOnly(d) => d.format(STABLE_FORMAT_DATEONLY).to_string(),
114            LooseDateTime::Floating(dt) => dt.format(STABLE_FORMAT_FLOATING).to_string(),
115            LooseDateTime::Local(dt) => dt.format(STABLE_FORMAT_LOCAL).to_string(),
116        }
117    }
118
119    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
120        match s.len() {
121            // 2006-01-02
122            10 => NaiveDate::parse_from_str(s, STABLE_FORMAT_DATEONLY)
123                .map(Self::DateOnly)
124                .ok(),
125
126            // 2006-01-02T15:04:05
127            19 => NaiveDateTime::parse_from_str(s, STABLE_FORMAT_FLOATING)
128                .map(Self::Floating)
129                .ok(),
130
131            // 2006-01-02T15:04:05Z
132            20.. => DateTime::parse_from_str(s, STABLE_FORMAT_LOCAL)
133                .map(|a| Self::Local(a.with_timezone(&Local)))
134                .ok(),
135
136            _ => None,
137        }
138    }
139}
140
141impl From<DatePerhapsTime> for LooseDateTime {
142    #[tracing::instrument]
143    fn from(dt: DatePerhapsTime) -> Self {
144        match dt {
145            DatePerhapsTime::DateTime(dt) => match dt {
146                CalendarDateTime::Floating(dt) => dt.into(),
147                CalendarDateTime::Utc(dt) => dt.into(),
148                CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
149                    Ok(tz) => match tz.from_local_datetime(&date_time) {
150                        // Use the parsed timezone to interpret the datetime
151                        LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
152                        LocalResult::Ambiguous(dt1, _) => {
153                            tracing::warn!(tzid, "ambiguous local time, picking earliest");
154                            dt1.into()
155                        }
156                        LocalResult::None => {
157                            tracing::warn!(tzid, "invalid local time, falling back to floating");
158                            date_time.into()
159                        }
160                    },
161                    Err(_) => {
162                        tracing::warn!(tzid, "unknown timezone, treating as floating");
163                        date_time.into()
164                    }
165                },
166            },
167            DatePerhapsTime::Date(d) => d.into(),
168        }
169    }
170}
171
172impl From<LooseDateTime> for DatePerhapsTime {
173    fn from(dt: LooseDateTime) -> Self {
174        match dt {
175            LooseDateTime::DateOnly(d) => d.into(),
176            LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
177            LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
178                Ok(tzid) => CalendarDateTime::WithTimezone {
179                    date_time: dt.naive_local(),
180                    tzid,
181                }
182                .into(),
183                Err(_) => {
184                    tracing::warn!("Failed to get timezone, using UTC");
185                    CalendarDateTime::Utc(dt.into()).into()
186                }
187            },
188        }
189    }
190}
191
192impl From<NaiveDate> for LooseDateTime {
193    fn from(d: NaiveDate) -> Self {
194        LooseDateTime::DateOnly(d)
195    }
196}
197
198impl From<NaiveDateTime> for LooseDateTime {
199    fn from(dt: NaiveDateTime) -> Self {
200        LooseDateTime::Floating(dt)
201    }
202}
203
204impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
205    fn from(dt: DateTime<Tz>) -> Self {
206        LooseDateTime::Local(dt.with_timezone(&Local))
207    }
208}
209
210impl Add<chrono::TimeDelta> for LooseDateTime {
211    type Output = Self;
212
213    fn add(self, rhs: chrono::TimeDelta) -> Self::Output {
214        match self {
215            LooseDateTime::DateOnly(d) => LooseDateTime::DateOnly(d.add(rhs)),
216            LooseDateTime::Floating(dt) => LooseDateTime::Floating(dt.add(rhs)),
217            LooseDateTime::Local(dt) => LooseDateTime::Local(dt.add(rhs)),
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use chrono::TimeDelta;
225
226    use super::*;
227
228    #[test]
229    fn provides_date_and_time_accessors() {
230        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
231        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
232        let datetime = NaiveDateTime::new(date, time);
233        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
234
235        let d1 = LooseDateTime::DateOnly(date);
236        let d2 = LooseDateTime::Floating(datetime);
237        let d3 = LooseDateTime::Local(local_dt);
238
239        // Date
240        assert_eq!(d1.date(), date);
241        assert_eq!(d2.date(), date);
242        assert_eq!(d3.date(), date);
243
244        // Time
245        assert_eq!(d1.time(), None);
246        assert_eq!(d2.time(), Some(time));
247        assert_eq!(d3.time(), Some(time));
248    }
249
250    #[test]
251    fn sets_time_to_start_of_day() {
252        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
253        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
254        let datetime = NaiveDateTime::new(date, time);
255        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
256
257        let d1 = LooseDateTime::DateOnly(date);
258        let d2 = LooseDateTime::Floating(datetime);
259        let d3 = LooseDateTime::Local(local_dt);
260
261        assert_eq!(
262            d1.with_start_of_day(),
263            NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
264        );
265        assert_eq!(d2.with_start_of_day(), datetime);
266        assert_eq!(d3.with_start_of_day(), datetime);
267    }
268
269    #[test]
270    fn sets_time_to_end_of_day() {
271        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
272        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
273        let datetime = NaiveDateTime::new(date, time);
274        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
275
276        let d1 = LooseDateTime::DateOnly(date);
277        let d2 = LooseDateTime::Floating(datetime);
278        let d3 = LooseDateTime::Local(local_dt);
279
280        assert_eq!(
281            d1.with_end_of_day(),
282            NaiveDateTime::new(
283                date,
284                NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
285            )
286        );
287        assert_eq!(d2.with_end_of_day(), datetime);
288        assert_eq!(d3.with_end_of_day(), datetime);
289    }
290
291    #[expect(clippy::many_single_char_names)]
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 calculates_position_in_date_date_range() {
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 calculates_position_in_date_floating_range() {
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 calculates_position_in_floating_date_range() {
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 calculates_position_with_end_only() {
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 calculates_position_with_start_only() {
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 returns_invalid_range_for_inverted_or_missing_bounds() {
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 creates_from_local_datetime() {
446        // Test with a valid datetime that should produce a single result
447        let datetime = DateTime::from_timestamp(1_609_459_200, 0)
448            .expect("Valid timestamp for 2021-01-01 00:00:00")
449            .naive_local();
450        let loose_dt = LooseDateTime::from_local_datetime(datetime);
451
452        // Should convert to Local variant
453        assert!(matches!(loose_dt, LooseDateTime::Local(_)));
454    }
455
456    #[test]
457    fn serializes_and_deserializes_stably() {
458        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
459        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
460        let datetime = NaiveDateTime::new(date, time);
461        let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
462
463        let d1 = LooseDateTime::DateOnly(date);
464        let d2 = LooseDateTime::Floating(datetime);
465        let d3 = LooseDateTime::Local(local);
466
467        // Format
468        let f1 = d1.format_stable();
469        let f2 = d2.format_stable();
470        let f3 = d3.format_stable();
471
472        assert_eq!(f1, "2024-07-18");
473        assert_eq!(f2, "2024-07-18T12:30:45");
474        assert!(f3.starts_with("2024-07-18T12:30:45"));
475
476        // Parse
477        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
478        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
479        let parsed3 = LooseDateTime::parse_stable(&f3);
480        if let Some(LooseDateTime::Local(dt)) = parsed3 {
481            assert_eq!(dt.naive_local(), local.naive_local());
482        } else {
483            panic!("Failed to parse local datetime");
484        }
485    }
486
487    #[test]
488    fn adds_timedelta_to_dateonly() {
489        let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
490        let added = LooseDateTime::DateOnly(date) + TimeDelta::days(2) + TimeDelta::hours(3);
491        let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 3).unwrap());
492        assert_eq!(added, expected);
493    }
494
495    #[test]
496    fn adds_timedelta_to_floating() {
497        let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
498        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
499        let dt = LooseDateTime::Floating(NaiveDateTime::new(date, time));
500        let added = dt + TimeDelta::days(2) + TimeDelta::hours(3);
501        let excepted = LooseDateTime::Floating(NaiveDateTime::new(
502            NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
503            NaiveTime::from_hms_opt(15, 30, 45).unwrap(),
504        ));
505        assert_eq!(added, excepted);
506    }
507
508    #[test]
509    fn adds_timedelta_to_local() {
510        let local = Local.with_ymd_and_hms(2025, 1, 1, 12, 30, 45).unwrap();
511        let added = LooseDateTime::Local(local) + TimeDelta::days(2) + TimeDelta::hours(3);
512        let excepted =
513            LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 3, 15, 30, 45).unwrap());
514        assert_eq!(added, excepted);
515    }
516}