Skip to main content

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 aimcal_ical::{self as ical, Segments};
8use jiff::civil::{self, Date, DateTime};
9use jiff::tz::TimeZone;
10use jiff::{Span, Zoned};
11
12use crate::RangePosition;
13use crate::datetime::util::{
14    STABLE_FORMAT_DATEONLY, STABLE_FORMAT_FLOATING, STABLE_FORMAT_LOCAL, end_of_day, start_of_day,
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, PartialEq, Eq)]
19pub enum LooseDateTime {
20    /// Date only without time.
21    DateOnly(Date),
22    /// Floating date and time without timezone.
23    Floating(DateTime),
24    /// Local date and time with timezone.
25    /// NOTE: This is always in the local timezone of the system running the code.
26    Local(Zoned),
27}
28
29impl LooseDateTime {
30    /// The date part.
31    #[must_use]
32    pub fn date(&self) -> Date {
33        match self {
34            LooseDateTime::DateOnly(d) => *d,
35            LooseDateTime::Floating(dt) => dt.date(),
36            LooseDateTime::Local(zoned) => zoned.date(),
37        }
38    }
39
40    /// The time part, if available.
41    #[must_use]
42    pub fn time(&self) -> Option<civil::Time> {
43        match self {
44            LooseDateTime::DateOnly(_) => None,
45            LooseDateTime::Floating(dt) => Some(dt.time()),
46            LooseDateTime::Local(zoned) => Some(zoned.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) -> DateTime {
52        let d = self.date();
53        let t = self.time().unwrap_or_else(start_of_day);
54        DateTime::from_parts(d, t)
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) -> DateTime {
59        let d = self.date();
60        let t = self.time().unwrap_or_else(end_of_day);
61        DateTime::from_parts(d, t)
62    }
63
64    /// Determines the position of a given datetime relative to a start and optional end date.
65    #[must_use]
66    pub fn position_in_range(
67        t: &DateTime,
68        start: &Option<LooseDateTime>,
69        end: &Option<LooseDateTime>,
70    ) -> RangePosition {
71        match (start, end) {
72            (Some(start), Some(end)) => {
73                let start_dt = start.with_start_of_day(); // 00:00
74                let end_dt = end.with_end_of_day(); // 23:59
75                if start_dt > end_dt {
76                    RangePosition::InvalidRange
77                } else if t > &end_dt {
78                    RangePosition::After
79                } else if t < &start_dt {
80                    RangePosition::Before
81                } else {
82                    RangePosition::InRange
83                }
84            }
85            (Some(start), None) => {
86                if t >= &start.with_start_of_day() {
87                    RangePosition::InRange
88                } else {
89                    RangePosition::Before
90                }
91            }
92            (None, Some(end)) => {
93                if t > &end.with_end_of_day() {
94                    RangePosition::After
95                } else {
96                    RangePosition::InRange
97                }
98            }
99            (None, None) => RangePosition::InvalidRange,
100        }
101    }
102
103    /// Creates a `LooseDateTime` from a `DateTime` in the local timezone.
104    pub(crate) fn from_local_datetime(dt: DateTime) -> LooseDateTime {
105        // Try to interpret the datetime in the system timezone
106        let tz = TimeZone::system();
107        if let Ok(zoned) = dt.to_zoned(tz) {
108            LooseDateTime::Local(zoned)
109        } else {
110            // Fallback to floating if timezone conversion fails
111            tracing::warn!(
112                ?dt,
113                "failed to convert to local timezone, treating as floating"
114            );
115            LooseDateTime::Floating(dt)
116        }
117    }
118
119    /// Converts to a string representation of date and time.
120    pub(crate) fn format_stable(&self) -> String {
121        match self {
122            LooseDateTime::DateOnly(d) => d.strftime(STABLE_FORMAT_DATEONLY).to_string(),
123            LooseDateTime::Floating(dt) => dt.strftime(STABLE_FORMAT_FLOATING).to_string(),
124            LooseDateTime::Local(zoned) => zoned.strftime(STABLE_FORMAT_LOCAL).to_string(),
125        }
126    }
127
128    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
129        match s.len() {
130            // 2006-01-02
131            10 => Date::strptime(STABLE_FORMAT_DATEONLY, s)
132                .ok()
133                .map(Self::DateOnly),
134            // 2006-01-02T15:04:05
135            19 => DateTime::strptime(STABLE_FORMAT_FLOATING, s)
136                .ok()
137                .map(Self::Floating),
138            // 2006-01-02T15:04:05Z or 2006-01-02T15:04:05+00:00
139            20.. => Zoned::strptime(STABLE_FORMAT_LOCAL, s)
140                .ok()
141                .map(Self::Local),
142            _ => None,
143        }
144    }
145}
146
147impl From<ical::DateTimeProperty<Segments<'_>>> for LooseDateTime {
148    #[tracing::instrument]
149    fn from(dt: ical::DateTimeProperty<Segments<'_>>) -> Self {
150        let date = dt.date();
151        let time = dt.time();
152
153        if dt.is_date_only() {
154            LooseDateTime::DateOnly(date.into())
155        } else if dt.is_utc() {
156            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
157            LooseDateTime::Local(civil_dt.to_zoned(TimeZone::UTC).unwrap())
158        } else if dt.is_floating() {
159            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
160            LooseDateTime::Floating(civil_dt)
161        } else if dt.is_zoned() {
162            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
163            if let Some(tz_id) = &dt.tz_id {
164                let tz_id_str = tz_id.to_string();
165                if let Ok(tz) = TimeZone::get(tz_id_str.as_str()) {
166                    if let Ok(zoned) = civil_dt.to_zoned(tz) {
167                        LooseDateTime::Local(zoned)
168                    } else {
169                        tracing::warn!(tzid = %tz_id_str, "unknown timezone, treating as floating");
170                        LooseDateTime::Floating(civil_dt)
171                    }
172                } else {
173                    tracing::warn!(tzid = %tz_id_str, "unknown timezone, treating as floating");
174                    LooseDateTime::Floating(civil_dt)
175                }
176            } else {
177                tracing::warn!("zoned datetime without tz_id, treating as floating");
178                LooseDateTime::Floating(civil_dt)
179            }
180        } else {
181            // This should not happen, but fallback to floating
182            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
183            LooseDateTime::Floating(civil_dt)
184        }
185    }
186}
187
188impl From<ical::DateTimeProperty<String>> for LooseDateTime {
189    fn from(dt: ical::DateTimeProperty<String>) -> Self {
190        let date = dt.date();
191        let time = dt.time();
192
193        if dt.is_date_only() {
194            LooseDateTime::DateOnly(date.into())
195        } else if dt.is_utc() {
196            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
197            LooseDateTime::Local(civil_dt.to_zoned(TimeZone::UTC).unwrap())
198        } else if dt.is_floating() {
199            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
200            LooseDateTime::Floating(civil_dt)
201        } else if dt.is_zoned() {
202            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
203            if let Some(tz_id) = &dt.tz_id {
204                if let Ok(tz) = TimeZone::get(tz_id.as_str()) {
205                    if let Ok(zoned) = civil_dt.to_zoned(tz) {
206                        LooseDateTime::Local(zoned)
207                    } else {
208                        tracing::warn!(tzid = %tz_id, "unknown timezone, treating as floating");
209                        LooseDateTime::Floating(civil_dt)
210                    }
211                } else {
212                    tracing::warn!(tzid = %tz_id, "unknown timezone, treating as floating");
213                    LooseDateTime::Floating(civil_dt)
214                }
215            } else {
216                tracing::warn!("zoned datetime without tz_id, treating as floating");
217                LooseDateTime::Floating(civil_dt)
218            }
219        } else {
220            // This should not happen, but fallback to floating
221            let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
222            LooseDateTime::Floating(civil_dt)
223        }
224    }
225}
226
227impl From<LooseDateTime> for ical::DateTimeProperty<String> {
228    fn from(dt: LooseDateTime) -> Self {
229        match dt {
230            LooseDateTime::DateOnly(d) => {
231                ical::DateTimeProperty::date_only(d.into(), Vec::new(), Vec::new(), ())
232            }
233            LooseDateTime::Floating(dt) => {
234                let date = dt.date().into();
235                let time = dt.time().into();
236                ical::DateTimeProperty::floating(date, time, Vec::new(), Vec::new(), ())
237            }
238            LooseDateTime::Local(zoned) => {
239                let tz = zoned.time_zone();
240                if *tz != TimeZone::UTC
241                    && let Some(tz_name) = tz.iana_name()
242                {
243                    let date = zoned.date().into();
244                    let time = zoned.time().into();
245                    ical::DateTimeProperty::zoned(
246                        date,
247                        time,
248                        tz_name.to_string(),
249                        Some(tz.clone()),
250                        Vec::new(),
251                        Vec::new(),
252                        (),
253                    )
254                } else {
255                    // Convert to UTC for iCalendar output
256                    let utc_dt = zoned.with_time_zone(TimeZone::UTC);
257                    let date = utc_dt.date().into();
258                    let time = utc_dt.time().into();
259                    ical::DateTimeProperty::utc(date, time, Vec::new(), Vec::new(), ())
260                }
261            }
262        }
263    }
264}
265
266impl From<Date> for LooseDateTime {
267    fn from(d: Date) -> Self {
268        LooseDateTime::DateOnly(d)
269    }
270}
271
272impl From<DateTime> for LooseDateTime {
273    fn from(dt: DateTime) -> Self {
274        LooseDateTime::Floating(dt)
275    }
276}
277
278impl From<Zoned> for LooseDateTime {
279    fn from(zoned: Zoned) -> Self {
280        LooseDateTime::Local(zoned)
281    }
282}
283
284impl Add<Span> for LooseDateTime {
285    type Output = Self;
286
287    fn add(self, rhs: Span) -> Self::Output {
288        match self {
289            LooseDateTime::DateOnly(d) => LooseDateTime::DateOnly(d.checked_add(rhs).unwrap()),
290            LooseDateTime::Floating(dt) => LooseDateTime::Floating(dt.checked_add(rhs).unwrap()),
291            LooseDateTime::Local(zoned) => LooseDateTime::Local(zoned.checked_add(rhs).unwrap()),
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use jiff::Span;
299    use jiff::civil::{date, datetime, time};
300    use jiff::tz::TimeZone;
301
302    use super::*;
303
304    #[test]
305    fn provides_date_and_time_accessors() {
306        let date = date(2024, 7, 18);
307        let time = time(12, 30, 45, 0);
308        let datetime = datetime(2024, 7, 18, 12, 30, 45, 0);
309        let tz = TimeZone::UTC;
310        let zoned_dt = datetime.to_zoned(tz).unwrap();
311
312        let d1 = LooseDateTime::DateOnly(date);
313        let d2 = LooseDateTime::Floating(datetime);
314        let d3 = LooseDateTime::Local(zoned_dt);
315
316        // Date
317        assert_eq!(d1.date(), date);
318        assert_eq!(d2.date(), date);
319        assert_eq!(d3.date(), date);
320
321        // Time
322        assert_eq!(d1.time(), None);
323        assert_eq!(d2.time(), Some(time));
324        assert_eq!(d3.time(), Some(time));
325    }
326
327    #[test]
328    fn sets_time_to_start_of_day() {
329        let d = date(2024, 7, 18);
330        let t = time(12, 30, 0, 0);
331        let datetime = DateTime::from_parts(d, t);
332        let tz = TimeZone::UTC;
333        let zoned_dt = datetime.to_zoned(tz).unwrap();
334
335        let d1 = LooseDateTime::DateOnly(d);
336        let d2 = LooseDateTime::Floating(datetime);
337        let d3 = LooseDateTime::Local(zoned_dt);
338
339        assert_eq!(
340            d1.with_start_of_day(),
341            DateTime::from_parts(d, time(0, 0, 0, 0))
342        );
343        assert_eq!(d2.with_start_of_day(), datetime);
344        assert_eq!(d3.with_start_of_day(), datetime);
345    }
346
347    #[test]
348    fn sets_time_to_end_of_day() {
349        let d = date(2024, 7, 18);
350        let t = time(12, 30, 0, 0);
351        let datetime = DateTime::from_parts(d, t);
352        let tz = TimeZone::UTC;
353        let zoned_dt = datetime.to_zoned(tz).unwrap();
354
355        let d1 = LooseDateTime::DateOnly(d);
356        let d2 = LooseDateTime::Floating(datetime);
357        let d3 = LooseDateTime::Local(zoned_dt);
358
359        assert_eq!(
360            d1.with_end_of_day(),
361            DateTime::from_parts(d, time(23, 59, 59, 999_999_999))
362        );
363        assert_eq!(d2.with_end_of_day(), datetime);
364        assert_eq!(d3.with_end_of_day(), datetime);
365    }
366
367    #[test]
368    fn calculates_position_in_date_date_range() {
369        let start = LooseDateTime::DateOnly(date(2024, 1, 1));
370        let end = LooseDateTime::DateOnly(date(2024, 1, 3));
371
372        let t_before = datetime(2023, 12, 31, 23, 59, 59, 0);
373        let t_in_s = datetime(2024, 1, 1, 12, 0, 0, 0);
374        let t_in_e = datetime(2024, 1, 3, 12, 0, 0, 0);
375        let t_after = datetime(2024, 1, 4, 0, 0, 0, 0);
376
377        assert_eq!(
378            LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
379            RangePosition::Before
380        );
381        assert_eq!(
382            LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
383            RangePosition::InRange
384        );
385        assert_eq!(
386            LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
387            RangePosition::InRange
388        );
389        assert_eq!(
390            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
391            RangePosition::After
392        );
393    }
394
395    #[test]
396    fn calculates_position_in_date_floating_range() {
397        let start = LooseDateTime::DateOnly(date(2024, 1, 1));
398        let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0, 0));
399
400        let t_before = datetime(2023, 12, 31, 23, 59, 59, 0);
401        let t_in_s = datetime(2024, 1, 1, 12, 0, 0, 0);
402        let t_in_e = datetime(2024, 1, 3, 12, 0, 0, 0);
403        let t_after = datetime(2024, 1, 3, 14, 0, 0, 0);
404
405        assert_eq!(
406            LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
407            RangePosition::Before
408        );
409        assert_eq!(
410            LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
411            RangePosition::InRange
412        );
413        assert_eq!(
414            LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
415            RangePosition::InRange
416        );
417        assert_eq!(
418            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
419            RangePosition::After
420        );
421    }
422
423    #[test]
424    fn calculates_position_in_floating_date_range() {
425        let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0, 0));
426        let end = LooseDateTime::DateOnly(date(2024, 1, 1));
427
428        let t_before = datetime(2024, 1, 1, 12, 0, 0, 0);
429        let t_in_s = datetime(2024, 1, 1, 14, 0, 0, 0);
430        let t_in_e = datetime(2024, 1, 1, 23, 59, 59, 0);
431        let t_after = datetime(2024, 1, 2, 0, 0, 0, 0);
432
433        assert_eq!(
434            LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
435            RangePosition::Before
436        );
437        assert_eq!(
438            LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
439            RangePosition::InRange
440        );
441        assert_eq!(
442            LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
443            RangePosition::InRange
444        );
445        assert_eq!(
446            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
447            RangePosition::After
448        );
449    }
450
451    #[test]
452    fn calculates_position_with_end_only() {
453        let t1 = datetime(2023, 12, 31, 23, 59, 59, 0);
454        let t2 = datetime(2024, 1, 1, 20, 0, 0, 0);
455
456        for end in [
457            LooseDateTime::DateOnly(date(2023, 12, 31)),
458            LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59, 0)),
459        ] {
460            assert_eq!(
461                LooseDateTime::position_in_range(&t1, &None, &Some(end.clone())),
462                RangePosition::InRange,
463                "end = {end:?}"
464            );
465            assert_eq!(
466                LooseDateTime::position_in_range(&t2, &None, &Some(end.clone())),
467                RangePosition::After,
468                "end = {end:?}"
469            );
470        }
471    }
472
473    #[test]
474    fn calculates_position_with_start_only() {
475        let t1 = datetime(2023, 12, 31, 23, 59, 59, 0);
476        let t2 = datetime(2024, 1, 1, 0, 0, 0, 0);
477
478        for start in [
479            LooseDateTime::DateOnly(date(2024, 1, 1)),
480            LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0, 0)),
481        ] {
482            assert_eq!(
483                LooseDateTime::position_in_range(&t1, &Some(start.clone()), &None),
484                RangePosition::Before,
485                "start = {start:?}"
486            );
487            assert_eq!(
488                LooseDateTime::position_in_range(&t2, &Some(start.clone()), &None),
489                RangePosition::InRange,
490                "start = {start:?}"
491            );
492        }
493    }
494
495    #[test]
496    fn returns_invalid_range_for_inverted_or_missing_bounds() {
497        let start = LooseDateTime::DateOnly(date(2024, 1, 5));
498        let end = LooseDateTime::DateOnly(date(2024, 1, 1));
499
500        let t = datetime(2024, 1, 3, 12, 0, 0, 0);
501
502        assert_eq!(
503            LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
504            RangePosition::InvalidRange
505        );
506
507        assert_eq!(
508            LooseDateTime::position_in_range(&t, &None, &None),
509            RangePosition::InvalidRange
510        );
511    }
512
513    #[test]
514    fn creates_from_local_datetime() {
515        // Test with a valid datetime
516        let date = date(2021, 1, 1);
517        let time = time(0, 0, 0, 0);
518        let datetime = DateTime::from_parts(date, time);
519        let loose_dt = LooseDateTime::from_local_datetime(datetime);
520
521        // Should convert to Local variant
522        assert!(matches!(loose_dt, LooseDateTime::Local(_)));
523    }
524
525    #[test]
526    fn serializes_and_deserializes_stably() {
527        let date = date(2024, 7, 18);
528        let time = time(12, 30, 45, 0);
529        let datetime = DateTime::from_parts(date, time);
530        let tz = TimeZone::UTC;
531        let local = datetime.to_zoned(tz).unwrap();
532
533        let d1 = LooseDateTime::DateOnly(date);
534        let d2 = LooseDateTime::Floating(datetime);
535        let d3 = LooseDateTime::Local(local.clone());
536
537        // Format
538        let f1 = d1.format_stable();
539        let f2 = d2.format_stable();
540        let f3 = d3.format_stable();
541
542        assert_eq!(f1, "2024-07-18");
543        assert_eq!(f2, "2024-07-18T12:30:45");
544        assert!(f3.starts_with("2024-07-18T12:30:45"));
545
546        // Parse
547        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
548        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
549        let parsed3 = LooseDateTime::parse_stable(&f3);
550        if let Some(LooseDateTime::Local(zoned)) = parsed3 {
551            assert_eq!(zoned.datetime(), local.datetime());
552        } else {
553            panic!("Failed to parse local datetime");
554        }
555    }
556
557    #[test]
558    fn adds_span_to_dateonly() {
559        let d = date(2025, 1, 1);
560        let added = LooseDateTime::DateOnly(d) + Span::new().days(2).hours(3);
561        let expected = LooseDateTime::DateOnly(date(2025, 1, 3));
562        assert_eq!(added, expected);
563    }
564
565    #[test]
566    fn adds_span_to_floating() {
567        let d = date(2025, 1, 1);
568        let t = time(12, 30, 45, 0);
569        let dt = LooseDateTime::Floating(DateTime::from_parts(d, t));
570        let added = dt + Span::new().days(2).hours(3);
571        let expected_date = date(2025, 1, 3);
572        let expected_time = time(15, 30, 45, 0);
573        let excepted = LooseDateTime::Floating(DateTime::from_parts(expected_date, expected_time));
574        assert_eq!(added, excepted);
575    }
576
577    #[test]
578    fn adds_span_to_local() {
579        let tz = TimeZone::UTC;
580        let d = date(2025, 1, 1);
581        let t = time(12, 30, 45, 0);
582        let datetime = DateTime::from_parts(d, t);
583        let zoned = datetime.to_zoned(tz.clone()).unwrap();
584        let added = LooseDateTime::Local(zoned.clone()) + Span::new().days(2).hours(3);
585        let expected_date = date(2025, 1, 3);
586        let expected_time = time(15, 30, 45, 0);
587        let expected_datetime = DateTime::from_parts(expected_date, expected_time);
588        let excepted = LooseDateTime::Local(expected_datetime.to_zoned(tz).unwrap());
589        assert_eq!(added, excepted);
590    }
591}