1use chrono::offset::LocalResult;
6use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, 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(self.date(), self.time().unwrap_or_else(start_of_day_naive))
46 }
47
48 pub fn with_end_of_day(&self) -> NaiveDateTime {
50 NaiveDateTime::new(self.date(), self.time().unwrap_or_else(end_of_day_naive))
51 }
52
53 pub fn position_in_range(
55 t: &NaiveDateTime,
56 start: &Option<LooseDateTime>,
57 end: &Option<LooseDateTime>,
58 ) -> RangePosition {
59 match (start, end) {
60 (Some(start), Some(end)) => {
61 let start_dt = start.with_start_of_day(); let end_dt = end.with_end_of_day(); if start_dt > end_dt {
64 RangePosition::InvalidRange
65 } else if t > &end_dt {
66 RangePosition::After
67 } else if t < &start_dt {
68 RangePosition::Before
69 } else {
70 RangePosition::InRange
71 }
72 }
73 (Some(start), None) => match t >= &start.with_start_of_day() {
74 true => RangePosition::InRange,
75 false => RangePosition::Before,
76 },
77 (None, Some(end)) => match t > &end.with_end_of_day() {
78 true => RangePosition::After,
79 false => RangePosition::InRange,
80 },
81 (None, None) => RangePosition::InvalidRange,
82 }
83 }
84
85 const DATEONLY_FORMAT: &str = "%Y-%m-%d";
87 const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
88 const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
89
90 pub(crate) fn format_stable(&self) -> String {
92 match self {
93 LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
94 LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
95 LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
96 }
97 }
98
99 pub(crate) fn parse_stable(s: &str) -> Option<Self> {
100 match s.len() {
101 10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
103 .map(Self::DateOnly)
104 .ok(),
105
106 19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
108 .map(Self::Floating)
109 .ok(),
110
111 20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
113 .map(|a| Self::Local(a.with_timezone(&Local)))
114 .ok(),
115
116 _ => None,
117 }
118 }
119}
120
121impl From<DatePerhapsTime> for LooseDateTime {
122 #[tracing::instrument]
123 fn from(dt: DatePerhapsTime) -> Self {
124 match dt {
125 DatePerhapsTime::DateTime(dt) => match dt {
126 CalendarDateTime::Floating(dt) => dt.into(),
127 CalendarDateTime::Utc(dt) => dt.into(),
128 CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
129 Ok(tz) => match tz.from_local_datetime(&date_time) {
130 LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
132 LocalResult::Ambiguous(dt1, _) => {
133 tracing::warn!(tzid, "ambiguous local time, picking earliest");
134 dt1.into()
135 }
136 LocalResult::None => {
137 tracing::warn!(tzid, "invalid local time, falling back to floating");
138 date_time.into()
139 }
140 },
141 Err(_) => {
142 tracing::warn!(tzid, "unknown timezone, treating as floating");
143 date_time.into()
144 }
145 },
146 },
147 DatePerhapsTime::Date(d) => d.into(),
148 }
149 }
150}
151
152impl From<LooseDateTime> for DatePerhapsTime {
153 fn from(dt: LooseDateTime) -> Self {
154 match dt {
155 LooseDateTime::DateOnly(d) => d.into(),
156 LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
157 LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
158 Ok(tzid) => CalendarDateTime::WithTimezone {
159 date_time: dt.naive_local(),
160 tzid,
161 }
162 .into(),
163 Err(_) => {
164 tracing::warn!("Failed to get timezone, using UTC");
165 CalendarDateTime::Utc(dt.into()).into()
166 }
167 },
168 }
169 }
170}
171
172impl From<NaiveDate> for LooseDateTime {
173 fn from(d: NaiveDate) -> Self {
174 LooseDateTime::DateOnly(d)
175 }
176}
177
178impl From<NaiveDateTime> for LooseDateTime {
179 fn from(dt: NaiveDateTime) -> Self {
180 LooseDateTime::Floating(dt)
181 }
182}
183
184impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
185 fn from(dt: DateTime<Tz>) -> Self {
186 LooseDateTime::Local(dt.with_timezone(&Local))
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum RangePosition {
193 Before,
195
196 InRange,
198
199 After,
201
202 InvalidRange,
204}
205
206#[derive(Debug, Clone, Copy)]
208pub enum DateTimeAnchor {
209 InHours(i64),
211
212 InDays(i64),
214}
215
216impl DateTimeAnchor {
217 pub fn now() -> Self {
219 DateTimeAnchor::InHours(0)
220 }
221
222 pub fn today() -> Self {
224 DateTimeAnchor::InDays(0)
225 }
226
227 pub fn tomorrow() -> Self {
229 DateTimeAnchor::InDays(1)
230 }
231
232 pub fn yesterday() -> Self {
234 DateTimeAnchor::InDays(-1)
235 }
236
237 pub fn parse_as_start_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
239 match self {
240 DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
241 DateTimeAnchor::InDays(n) => start_of_day(now) + TimeDelta::days(*n),
242 }
243 }
244
245 pub fn parse_as_end_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
247 match self {
248 DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
249 DateTimeAnchor::InDays(n) => end_of_day(now) + TimeDelta::days(*n),
250 }
251 }
252}
253
254fn start_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
256 let naive = NaiveDateTime::new(dt.date_naive(), start_of_day_naive());
257 from_local_datetime(&dt.timezone(), naive)
258}
259
260fn end_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
262 let last_nano_sec = end_of_day_naive();
263 let naive = NaiveDateTime::new(dt.date_naive(), last_nano_sec);
264 from_local_datetime(&dt.timezone(), naive)
265}
266
267const fn start_of_day_naive() -> NaiveTime {
268 NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 must exist in NaiveTime")
269}
270
271const fn end_of_day_naive() -> NaiveTime {
273 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999)
274 .expect("23:59:59:1_999_999_999 must exist in NaiveTime")
275}
276
277fn from_local_datetime<Tz: TimeZone>(tz: &Tz, naive: NaiveDateTime) -> DateTime<Tz> {
283 match tz.from_local_datetime(&naive) {
284 LocalResult::Single(x) => x,
285 LocalResult::Ambiguous(a, b) => {
286 if a <= b { a } else { b }
288 }
289 LocalResult::None => {
290 let utc = chrono::Utc.from_utc_datetime(&naive);
291 utc.with_timezone(tz)
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use chrono::Utc;
299
300 use super::*;
301
302 #[test]
303 fn test_date_and_time_methods() {
304 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
305 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
306 let datetime = NaiveDateTime::new(date, time);
307 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
308
309 let d1 = LooseDateTime::DateOnly(date);
310 let d2 = LooseDateTime::Floating(datetime);
311 let d3 = LooseDateTime::Local(local_dt);
312
313 assert_eq!(d1.date(), date);
315 assert_eq!(d2.date(), date);
316 assert_eq!(d3.date(), date);
317
318 assert_eq!(d1.time(), None);
320 assert_eq!(d2.time(), Some(time));
321 assert_eq!(d3.time(), Some(time));
322 }
323
324 #[test]
325 fn test_with_start_of_day() {
326 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
327 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
328 let datetime = NaiveDateTime::new(date, time);
329 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
330
331 let d1 = LooseDateTime::DateOnly(date);
332 let d2 = LooseDateTime::Floating(datetime);
333 let d3 = LooseDateTime::Local(local_dt);
334
335 assert_eq!(
336 d1.with_start_of_day(),
337 NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
338 );
339 assert_eq!(d2.with_start_of_day(), datetime);
340 assert_eq!(d3.with_start_of_day(), datetime);
341 }
342
343 #[test]
344 fn test_with_end_of_day() {
345 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
346 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
347 let datetime = NaiveDateTime::new(date, time);
348 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
349
350 let d1 = LooseDateTime::DateOnly(date);
351 let d2 = LooseDateTime::Floating(datetime);
352 let d3 = LooseDateTime::Local(local_dt);
353
354 assert_eq!(
355 d1.with_end_of_day(),
356 NaiveDateTime::new(
357 date,
358 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
359 )
360 );
361 assert_eq!(d2.with_end_of_day(), datetime);
362 assert_eq!(d3.with_end_of_day(), datetime);
363 }
364
365 fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
366 NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
367 }
368
369 #[test]
370 fn test_position_in_range_date_date() {
371 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
372 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
373
374 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
375 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
376 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
377 let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
378
379 assert_eq!(
380 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
381 RangePosition::Before
382 );
383 assert_eq!(
384 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
385 RangePosition::InRange
386 );
387 assert_eq!(
388 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
389 RangePosition::InRange
390 );
391 assert_eq!(
392 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
393 RangePosition::After
394 );
395 }
396
397 #[test]
398 fn test_position_in_range_date_floating() {
399 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
400 let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
401
402 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
403 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
404 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
405 let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
406
407 assert_eq!(
408 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
409 RangePosition::Before
410 );
411 assert_eq!(
412 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
413 RangePosition::InRange
414 );
415 assert_eq!(
416 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
417 RangePosition::InRange
418 );
419 assert_eq!(
420 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
421 RangePosition::After
422 );
423 }
424
425 #[test]
426 fn test_position_in_range_floating_date() {
427 let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
428 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
429
430 let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
431 let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
432 let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
433 let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
434
435 assert_eq!(
436 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
437 RangePosition::Before
438 );
439 assert_eq!(
440 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
441 RangePosition::InRange
442 );
443 assert_eq!(
444 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
445 RangePosition::InRange
446 );
447 assert_eq!(
448 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
449 RangePosition::After
450 );
451 }
452
453 #[test]
454 fn test_position_in_range_without_start() {
455 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
456 let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
457
458 for end in [
459 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
460 LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
461 LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
462 ] {
463 assert_eq!(
464 LooseDateTime::position_in_range(&t1, &None, &Some(end)),
465 RangePosition::InRange,
466 "end = {end:?}"
467 );
468 assert_eq!(
469 LooseDateTime::position_in_range(&t2, &None, &Some(end)),
470 RangePosition::After,
471 "end = {end:?}"
472 );
473 }
474 }
475
476 #[test]
477 fn test_position_in_range_date_without_end() {
478 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
479 let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
480
481 for start in [
482 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
483 LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
484 LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
485 ] {
486 assert_eq!(
487 LooseDateTime::position_in_range(&t1, &Some(start), &None),
488 RangePosition::Before,
489 "start = {start:?}"
490 );
491 assert_eq!(
492 LooseDateTime::position_in_range(&t2, &Some(start), &None),
493 RangePosition::InRange,
494 "start = {start:?}"
495 );
496 }
497 }
498
499 #[test]
500 fn test_invalid_range() {
501 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
502 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
503
504 let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
505
506 assert_eq!(
507 LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
508 RangePosition::InvalidRange
509 );
510
511 assert_eq!(
512 LooseDateTime::position_in_range(&t, &None, &None),
513 RangePosition::InvalidRange
514 );
515 }
516
517 #[test]
518 fn test_format_and_parse_stable() {
519 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
520 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
521 let datetime = NaiveDateTime::new(date, time);
522 let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
523
524 let d1 = LooseDateTime::DateOnly(date);
525 let d2 = LooseDateTime::Floating(datetime);
526 let d3 = LooseDateTime::Local(local);
527
528 let f1 = d1.format_stable();
530 let f2 = d2.format_stable();
531 let f3 = d3.format_stable();
532
533 assert_eq!(f1, "2024-07-18");
534 assert_eq!(f2, "2024-07-18T12:30:45");
535 assert!(f3.starts_with("2024-07-18T12:30:45"));
536
537 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
539 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
540 let parsed3 = LooseDateTime::parse_stable(&f3);
541 if let Some(LooseDateTime::Local(dt)) = parsed3 {
542 assert_eq!(dt.naive_local(), local.naive_local());
543 } else {
544 panic!("Failed to parse local datetime");
545 }
546 }
547
548 #[test]
549 fn test_when_now() {
550 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
551 assert_eq!(DateTimeAnchor::now().parse_as_start_of_day(&now), now);
552 assert_eq!(DateTimeAnchor::now().parse_as_end_of_day(&now), now);
553 }
554
555 #[test]
556 fn test_when_in_hours() {
557 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
558 let anchor = DateTimeAnchor::InHours(1);
559
560 let parsed = anchor.parse_as_start_of_day(&now);
561 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 16, 30, 45).unwrap();
562 assert_eq!(parsed, expected);
563
564 let parsed = anchor.parse_as_end_of_day(&now);
565 assert_eq!(parsed, expected);
566 }
567
568 #[test]
569 fn test_when_in_days() {
570 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
571 let anchor = DateTimeAnchor::InDays(1);
572
573 let parsed = anchor.parse_as_start_of_day(&now);
574 let expected = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
575 assert_eq!(parsed, expected);
576
577 let parsed = anchor.parse_as_end_of_day(&now);
578 assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
579 assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
580 }
581
582 #[test]
583 fn test_start_of_day() {
584 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 59).unwrap();
585 let parsed = start_of_day(&now);
586 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
587 assert_eq!(parsed, expected);
588 }
589
590 #[test]
591 fn test_end_of_day() {
592 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
593 let parsed = end_of_day(&now);
594 let last_sec = Utc.with_ymd_and_hms(2025, 1, 1, 23, 59, 59).unwrap();
595 let next_day = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
596 assert!(parsed > last_sec);
597 assert!(parsed < next_day);
598 }
599
600 #[test]
601 fn test_from_local_datetime_dst_ambiguity_pick_earliest() {
602 let tz = chrono_tz::America::New_York; let now = NaiveDateTime::new(
604 NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
605 NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
606 );
607
608 let parsed = from_local_datetime(&tz, now).with_timezone(&Utc);
609 let expected = Utc.with_ymd_and_hms(2025, 11, 2, 5, 30, 0).unwrap();
610 assert_eq!(parsed, expected);
611 }
612}