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