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