1use chrono::offset::LocalResult;
6use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
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: &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(); 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 (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 const DATEONLY_FORMAT: &str = "%Y-%m-%d";
97 const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
98 const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
99
100 pub(crate) fn format_stable(&self) -> String {
102 match self {
103 LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
104 LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
105 LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
106 }
107 }
108
109 pub(crate) fn parse_stable(s: &str) -> Option<Self> {
110 match s.len() {
111 10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
113 .map(Self::DateOnly)
114 .ok(),
115
116 19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
118 .map(Self::Floating)
119 .ok(),
120
121 20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
123 .map(|a| Self::Local(a.with_timezone(&Local)))
124 .ok(),
125
126 _ => None,
127 }
128 }
129}
130
131impl From<DatePerhapsTime> for LooseDateTime {
132 #[tracing::instrument]
133 fn from(dt: DatePerhapsTime) -> Self {
134 match dt {
135 DatePerhapsTime::DateTime(dt) => match dt {
136 CalendarDateTime::Floating(dt) => dt.into(),
137 CalendarDateTime::Utc(dt) => dt.into(),
138 CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
139 Ok(tz) => match tz.from_local_datetime(&date_time) {
140 LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
142 LocalResult::Ambiguous(dt1, _) => {
143 tracing::warn!(tzid, "ambiguous local time, picking earliest");
144 dt1.into()
145 }
146 LocalResult::None => {
147 tracing::warn!(tzid, "invalid local time, falling back to floating");
148 date_time.into()
149 }
150 },
151 Err(_) => {
152 tracing::warn!(tzid, "unknown timezone, treating as floating");
153 date_time.into()
154 }
155 },
156 },
157 DatePerhapsTime::Date(d) => d.into(),
158 }
159 }
160}
161
162impl From<LooseDateTime> for DatePerhapsTime {
163 fn from(dt: LooseDateTime) -> Self {
164 match dt {
165 LooseDateTime::DateOnly(d) => d.into(),
166 LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
167 LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
168 Ok(tzid) => CalendarDateTime::WithTimezone {
169 date_time: dt.naive_local(),
170 tzid,
171 }
172 .into(),
173 Err(_) => {
174 tracing::warn!("Failed to get timezone, using UTC");
175 CalendarDateTime::Utc(dt.into()).into()
176 }
177 },
178 }
179 }
180}
181
182impl From<NaiveDate> for LooseDateTime {
183 fn from(d: NaiveDate) -> Self {
184 LooseDateTime::DateOnly(d)
185 }
186}
187
188impl From<NaiveDateTime> for LooseDateTime {
189 fn from(dt: NaiveDateTime) -> Self {
190 LooseDateTime::Floating(dt)
191 }
192}
193
194impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
195 fn from(dt: DateTime<Tz>) -> Self {
196 LooseDateTime::Local(dt.with_timezone(&Local))
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum RangePosition {
203 Before,
205
206 InRange,
208
209 After,
211
212 InvalidRange,
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use chrono::{NaiveDate, TimeZone};
220
221 #[test]
222 fn test_date_and_time_methods() {
223 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
224 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
225 let datetime = NaiveDateTime::new(date, time);
226 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
227
228 let d1 = LooseDateTime::DateOnly(date);
229 let d2 = LooseDateTime::Floating(datetime);
230 let d3 = LooseDateTime::Local(local_dt);
231
232 assert_eq!(d1.date(), date);
234 assert_eq!(d2.date(), date);
235 assert_eq!(d3.date(), date);
236
237 assert_eq!(d1.time(), None);
239 assert_eq!(d2.time(), Some(time));
240 assert_eq!(d3.time(), Some(time));
241 }
242
243 #[test]
244 fn test_with_start_of_day() {
245 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
246 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
247 let datetime = NaiveDateTime::new(date, time);
248 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
249
250 let d1 = LooseDateTime::DateOnly(date);
251 let d2 = LooseDateTime::Floating(datetime);
252 let d3 = LooseDateTime::Local(local_dt);
253
254 assert_eq!(
255 d1.with_start_of_day(),
256 NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
257 );
258 assert_eq!(d2.with_start_of_day(), datetime);
259 assert_eq!(d3.with_start_of_day(), datetime);
260 }
261
262 #[test]
263 fn test_with_end_of_day() {
264 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
265 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
266 let datetime = NaiveDateTime::new(date, time);
267 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
268
269 let d1 = LooseDateTime::DateOnly(date);
270 let d2 = LooseDateTime::Floating(datetime);
271 let d3 = LooseDateTime::Local(local_dt);
272
273 assert_eq!(
274 d1.with_end_of_day(),
275 NaiveDateTime::new(
276 date,
277 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
278 )
279 );
280 assert_eq!(d2.with_end_of_day(), datetime);
281 assert_eq!(d3.with_end_of_day(), datetime);
282 }
283
284 fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
285 NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
286 }
287
288 #[test]
289 fn test_position_in_range_date_date() {
290 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
291 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
292
293 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
294 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
295 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
296 let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
297
298 assert_eq!(
299 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
300 RangePosition::Before
301 );
302 assert_eq!(
303 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
304 RangePosition::InRange
305 );
306 assert_eq!(
307 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
308 RangePosition::InRange
309 );
310 assert_eq!(
311 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
312 RangePosition::After
313 );
314 }
315
316 #[test]
317 fn test_position_in_range_date_floating() {
318 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
319 let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
320
321 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
322 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
323 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
324 let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
325
326 assert_eq!(
327 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
328 RangePosition::Before
329 );
330 assert_eq!(
331 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
332 RangePosition::InRange
333 );
334 assert_eq!(
335 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
336 RangePosition::InRange
337 );
338 assert_eq!(
339 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
340 RangePosition::After
341 );
342 }
343
344 #[test]
345 fn test_position_in_range_floating_date() {
346 let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
347 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
348
349 let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
350 let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
351 let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
352 let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
353
354 assert_eq!(
355 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
356 RangePosition::Before
357 );
358 assert_eq!(
359 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
360 RangePosition::InRange
361 );
362 assert_eq!(
363 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
364 RangePosition::InRange
365 );
366 assert_eq!(
367 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
368 RangePosition::After
369 );
370 }
371
372 #[test]
373 fn test_position_in_range_without_start() {
374 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
375 let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
376
377 for end in [
378 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
379 LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
380 LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
381 ] {
382 assert_eq!(
383 LooseDateTime::position_in_range(&t1, &None, &Some(end)),
384 RangePosition::InRange,
385 "end = {end:?}"
386 );
387 assert_eq!(
388 LooseDateTime::position_in_range(&t2, &None, &Some(end)),
389 RangePosition::After,
390 "end = {end:?}"
391 );
392 }
393 }
394
395 #[test]
396 fn test_position_in_range_date_without_end() {
397 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
398 let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
399
400 for start in [
401 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
402 LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
403 LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
404 ] {
405 assert_eq!(
406 LooseDateTime::position_in_range(&t1, &Some(start), &None),
407 RangePosition::Before,
408 "start = {start:?}"
409 );
410 assert_eq!(
411 LooseDateTime::position_in_range(&t2, &Some(start), &None),
412 RangePosition::InRange,
413 "start = {start:?}"
414 );
415 }
416 }
417
418 #[test]
419 fn test_invalid_range() {
420 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
421 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
422
423 let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
424
425 assert_eq!(
426 LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
427 RangePosition::InvalidRange
428 );
429
430 assert_eq!(
431 LooseDateTime::position_in_range(&t, &None, &None),
432 RangePosition::InvalidRange
433 );
434 }
435
436 #[test]
437 fn test_format_and_parse_stable() {
438 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
439 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
440 let datetime = NaiveDateTime::new(date, time);
441 let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
442
443 let d1 = LooseDateTime::DateOnly(date);
444 let d2 = LooseDateTime::Floating(datetime);
445 let d3 = LooseDateTime::Local(local);
446
447 let f1 = d1.format_stable();
449 let f2 = d2.format_stable();
450 let f3 = d3.format_stable();
451
452 assert_eq!(f1, "2024-07-18");
453 assert_eq!(f2, "2024-07-18T12:30:45");
454 assert!(f3.starts_with("2024-07-18T12:30:45"));
455
456 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
458 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
459 let parsed3 = LooseDateTime::parse_stable(&f3);
460 if let Some(LooseDateTime::Local(dt)) = parsed3 {
461 assert_eq!(dt.naive_local(), local.naive_local());
462 } else {
463 panic!("Failed to parse local datetime");
464 }
465 }
466}