1use std::ops::Add;
6
7use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, offset::LocalResult};
8use chrono_tz::Tz;
9use icalendar::{CalendarDateTime, DatePerhapsTime};
10
11use crate::RangePosition;
12use crate::datetime::util::{
13 STABLE_FORMAT_DATEONLY, STABLE_FORMAT_FLOATING, STABLE_FORMAT_LOCAL, end_of_day_naive,
14 start_of_day_naive,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LooseDateTime {
20 DateOnly(NaiveDate),
22
23 Floating(NaiveDateTime),
25
26 Local(DateTime<Local>),
29}
30
31impl LooseDateTime {
32 pub fn date(&self) -> NaiveDate {
34 match self {
35 LooseDateTime::DateOnly(d) => *d,
36 LooseDateTime::Floating(dt) => dt.date(),
37 LooseDateTime::Local(dt) => dt.date_naive(),
38 }
39 }
40
41 pub fn time(&self) -> Option<NaiveTime> {
43 match self {
44 LooseDateTime::DateOnly(_) => None,
45 LooseDateTime::Floating(dt) => Some(dt.time()),
46 LooseDateTime::Local(dt) => Some(dt.time()),
47 }
48 }
49
50 pub fn with_start_of_day(&self) -> NaiveDateTime {
52 NaiveDateTime::new(self.date(), self.time().unwrap_or_else(start_of_day_naive))
53 }
54
55 pub fn with_end_of_day(&self) -> NaiveDateTime {
57 NaiveDateTime::new(self.date(), self.time().unwrap_or_else(end_of_day_naive))
58 }
59
60 pub fn position_in_range(
62 t: &NaiveDateTime,
63 start: &Option<LooseDateTime>,
64 end: &Option<LooseDateTime>,
65 ) -> RangePosition {
66 match (start, end) {
67 (Some(start), Some(end)) => {
68 let start_dt = start.with_start_of_day(); let end_dt = end.with_end_of_day(); if start_dt > end_dt {
71 RangePosition::InvalidRange
72 } else if t > &end_dt {
73 RangePosition::After
74 } else if t < &start_dt {
75 RangePosition::Before
76 } else {
77 RangePosition::InRange
78 }
79 }
80 (Some(start), None) => match t >= &start.with_start_of_day() {
81 true => RangePosition::InRange,
82 false => RangePosition::Before,
83 },
84 (None, Some(end)) => match t > &end.with_end_of_day() {
85 true => RangePosition::After,
86 false => RangePosition::InRange,
87 },
88 (None, None) => RangePosition::InvalidRange,
89 }
90 }
91
92 pub(crate) fn from_local_datetime(dt: NaiveDateTime) -> LooseDateTime {
94 match Local.from_local_datetime(&dt) {
95 LocalResult::Single(dt) => dt.into(),
96 LocalResult::Ambiguous(dt1, _) => {
97 tracing::warn!(?dt, "ambiguous local time in local, picking earliest");
98 dt1.into()
99 }
100 LocalResult::None => {
101 tracing::warn!(?dt, "invalid local time in local, falling back to floating");
102 dt.into()
103 }
104 }
105 }
106
107 pub(crate) fn format_stable(&self) -> String {
109 match self {
110 LooseDateTime::DateOnly(d) => d.format(STABLE_FORMAT_DATEONLY).to_string(),
111 LooseDateTime::Floating(dt) => dt.format(STABLE_FORMAT_FLOATING).to_string(),
112 LooseDateTime::Local(dt) => dt.format(STABLE_FORMAT_LOCAL).to_string(),
113 }
114 }
115
116 pub(crate) fn parse_stable(s: &str) -> Option<Self> {
117 match s.len() {
118 10 => NaiveDate::parse_from_str(s, STABLE_FORMAT_DATEONLY)
120 .map(Self::DateOnly)
121 .ok(),
122
123 19 => NaiveDateTime::parse_from_str(s, STABLE_FORMAT_FLOATING)
125 .map(Self::Floating)
126 .ok(),
127
128 20.. => DateTime::parse_from_str(s, STABLE_FORMAT_LOCAL)
130 .map(|a| Self::Local(a.with_timezone(&Local)))
131 .ok(),
132
133 _ => None,
134 }
135 }
136}
137
138impl From<DatePerhapsTime> for LooseDateTime {
139 #[tracing::instrument]
140 fn from(dt: DatePerhapsTime) -> Self {
141 match dt {
142 DatePerhapsTime::DateTime(dt) => match dt {
143 CalendarDateTime::Floating(dt) => dt.into(),
144 CalendarDateTime::Utc(dt) => dt.into(),
145 CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
146 Ok(tz) => match tz.from_local_datetime(&date_time) {
147 LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
149 LocalResult::Ambiguous(dt1, _) => {
150 tracing::warn!(tzid, "ambiguous local time, picking earliest");
151 dt1.into()
152 }
153 LocalResult::None => {
154 tracing::warn!(tzid, "invalid local time, falling back to floating");
155 date_time.into()
156 }
157 },
158 Err(_) => {
159 tracing::warn!(tzid, "unknown timezone, treating as floating");
160 date_time.into()
161 }
162 },
163 },
164 DatePerhapsTime::Date(d) => d.into(),
165 }
166 }
167}
168
169impl From<LooseDateTime> for DatePerhapsTime {
170 fn from(dt: LooseDateTime) -> Self {
171 match dt {
172 LooseDateTime::DateOnly(d) => d.into(),
173 LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
174 LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
175 Ok(tzid) => CalendarDateTime::WithTimezone {
176 date_time: dt.naive_local(),
177 tzid,
178 }
179 .into(),
180 Err(_) => {
181 tracing::warn!("Failed to get timezone, using UTC");
182 CalendarDateTime::Utc(dt.into()).into()
183 }
184 },
185 }
186 }
187}
188
189impl From<NaiveDate> for LooseDateTime {
190 fn from(d: NaiveDate) -> Self {
191 LooseDateTime::DateOnly(d)
192 }
193}
194
195impl From<NaiveDateTime> for LooseDateTime {
196 fn from(dt: NaiveDateTime) -> Self {
197 LooseDateTime::Floating(dt)
198 }
199}
200
201impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
202 fn from(dt: DateTime<Tz>) -> Self {
203 LooseDateTime::Local(dt.with_timezone(&Local))
204 }
205}
206
207impl Add<chrono::TimeDelta> for LooseDateTime {
208 type Output = Self;
209
210 fn add(self, rhs: chrono::TimeDelta) -> Self::Output {
211 match self {
212 LooseDateTime::DateOnly(d) => LooseDateTime::DateOnly(d.add(rhs)),
213 LooseDateTime::Floating(dt) => LooseDateTime::Floating(dt.add(rhs)),
214 LooseDateTime::Local(dt) => LooseDateTime::Local(dt.add(rhs)),
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use chrono::TimeDelta;
222
223 use super::*;
224
225 #[test]
226 fn test_date_and_time_methods() {
227 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
228 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
229 let datetime = NaiveDateTime::new(date, time);
230 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
231
232 let d1 = LooseDateTime::DateOnly(date);
233 let d2 = LooseDateTime::Floating(datetime);
234 let d3 = LooseDateTime::Local(local_dt);
235
236 assert_eq!(d1.date(), date);
238 assert_eq!(d2.date(), date);
239 assert_eq!(d3.date(), date);
240
241 assert_eq!(d1.time(), None);
243 assert_eq!(d2.time(), Some(time));
244 assert_eq!(d3.time(), Some(time));
245 }
246
247 #[test]
248 fn test_with_start_of_day() {
249 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
250 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
251 let datetime = NaiveDateTime::new(date, time);
252 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
253
254 let d1 = LooseDateTime::DateOnly(date);
255 let d2 = LooseDateTime::Floating(datetime);
256 let d3 = LooseDateTime::Local(local_dt);
257
258 assert_eq!(
259 d1.with_start_of_day(),
260 NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
261 );
262 assert_eq!(d2.with_start_of_day(), datetime);
263 assert_eq!(d3.with_start_of_day(), datetime);
264 }
265
266 #[test]
267 fn test_with_end_of_day() {
268 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
269 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
270 let datetime = NaiveDateTime::new(date, time);
271 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
272
273 let d1 = LooseDateTime::DateOnly(date);
274 let d2 = LooseDateTime::Floating(datetime);
275 let d3 = LooseDateTime::Local(local_dt);
276
277 assert_eq!(
278 d1.with_end_of_day(),
279 NaiveDateTime::new(
280 date,
281 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
282 )
283 );
284 assert_eq!(d2.with_end_of_day(), datetime);
285 assert_eq!(d3.with_end_of_day(), datetime);
286 }
287
288 fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
289 NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
290 }
291
292 #[test]
293 fn test_position_in_range_date_date() {
294 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
295 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
296
297 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
298 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
299 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
300 let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
301
302 assert_eq!(
303 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
304 RangePosition::Before
305 );
306 assert_eq!(
307 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
308 RangePosition::InRange
309 );
310 assert_eq!(
311 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
312 RangePosition::InRange
313 );
314 assert_eq!(
315 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
316 RangePosition::After
317 );
318 }
319
320 #[test]
321 fn test_position_in_range_date_floating() {
322 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
323 let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
324
325 let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
326 let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
327 let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
328 let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
329
330 assert_eq!(
331 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
332 RangePosition::Before
333 );
334 assert_eq!(
335 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
336 RangePosition::InRange
337 );
338 assert_eq!(
339 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
340 RangePosition::InRange
341 );
342 assert_eq!(
343 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
344 RangePosition::After
345 );
346 }
347
348 #[test]
349 fn test_position_in_range_floating_date() {
350 let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
351 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
352
353 let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
354 let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
355 let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
356 let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
357
358 assert_eq!(
359 LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
360 RangePosition::Before
361 );
362 assert_eq!(
363 LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
364 RangePosition::InRange
365 );
366 assert_eq!(
367 LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
368 RangePosition::InRange
369 );
370 assert_eq!(
371 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
372 RangePosition::After
373 );
374 }
375
376 #[test]
377 fn test_position_in_range_without_start() {
378 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
379 let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
380
381 for end in [
382 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
383 LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
384 LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
385 ] {
386 assert_eq!(
387 LooseDateTime::position_in_range(&t1, &None, &Some(end)),
388 RangePosition::InRange,
389 "end = {end:?}"
390 );
391 assert_eq!(
392 LooseDateTime::position_in_range(&t2, &None, &Some(end)),
393 RangePosition::After,
394 "end = {end:?}"
395 );
396 }
397 }
398
399 #[test]
400 fn test_position_in_range_date_without_end() {
401 let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
402 let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
403
404 for start in [
405 LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
406 LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
407 LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
408 ] {
409 assert_eq!(
410 LooseDateTime::position_in_range(&t1, &Some(start), &None),
411 RangePosition::Before,
412 "start = {start:?}"
413 );
414 assert_eq!(
415 LooseDateTime::position_in_range(&t2, &Some(start), &None),
416 RangePosition::InRange,
417 "start = {start:?}"
418 );
419 }
420 }
421
422 #[test]
423 fn test_invalid_range() {
424 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
425 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
426
427 let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
428
429 assert_eq!(
430 LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
431 RangePosition::InvalidRange
432 );
433
434 assert_eq!(
435 LooseDateTime::position_in_range(&t, &None, &None),
436 RangePosition::InvalidRange
437 );
438 }
439
440 #[test]
441 fn test_from_local_datetime() {
442 let datetime = DateTime::from_timestamp(1609459200, 0)
444 .expect("Valid timestamp for 2021-01-01 00:00:00")
445 .naive_local();
446 let loose_dt = LooseDateTime::from_local_datetime(datetime);
447
448 assert!(matches!(loose_dt, LooseDateTime::Local(_)));
450 }
451
452 #[test]
453 fn test_format_and_parse_stable() {
454 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
455 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
456 let datetime = NaiveDateTime::new(date, time);
457 let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
458
459 let d1 = LooseDateTime::DateOnly(date);
460 let d2 = LooseDateTime::Floating(datetime);
461 let d3 = LooseDateTime::Local(local);
462
463 let f1 = d1.format_stable();
465 let f2 = d2.format_stable();
466 let f3 = d3.format_stable();
467
468 assert_eq!(f1, "2024-07-18");
469 assert_eq!(f2, "2024-07-18T12:30:45");
470 assert!(f3.starts_with("2024-07-18T12:30:45"));
471
472 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
474 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
475 let parsed3 = LooseDateTime::parse_stable(&f3);
476 if let Some(LooseDateTime::Local(dt)) = parsed3 {
477 assert_eq!(dt.naive_local(), local.naive_local());
478 } else {
479 panic!("Failed to parse local datetime");
480 }
481 }
482
483 #[test]
484 fn test_add_timedelta_dateonly() {
485 let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
486 let added = LooseDateTime::DateOnly(date) + TimeDelta::days(2) + TimeDelta::hours(3);
487 let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 3).unwrap());
488 assert_eq!(added, expected);
489 }
490
491 #[test]
492 fn test_add_timedelta_floating() {
493 let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
494 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
495 let dt = LooseDateTime::Floating(NaiveDateTime::new(date, time));
496 let added = dt + TimeDelta::days(2) + TimeDelta::hours(3);
497 let excepted = LooseDateTime::Floating(NaiveDateTime::new(
498 NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
499 NaiveTime::from_hms_opt(15, 30, 45).unwrap(),
500 ));
501 assert_eq!(added, excepted);
502 }
503
504 #[test]
505 fn test_add_timedelta_local() {
506 let local = Local.with_ymd_and_hms(2025, 1, 1, 12, 30, 45).unwrap();
507 let added = LooseDateTime::Local(local) + TimeDelta::days(2) + TimeDelta::hours(3);
508 let excepted =
509 LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 3, 15, 30, 45).unwrap());
510 assert_eq!(added, excepted);
511 }
512}