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