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: &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 fn from(dt: DatePerhapsTime) -> Self {
133 match dt {
134 DatePerhapsTime::DateTime(dt) => match dt {
135 CalendarDateTime::Floating(dt) => LooseDateTime::Floating(dt),
136 CalendarDateTime::Utc(dt) => LooseDateTime::Local(dt.into()),
137 CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
138 Ok(tz) => match tz.from_local_datetime(&date_time) {
139 LocalResult::Single(dt_in_tz) => {
141 LooseDateTime::Local(dt_in_tz.with_timezone(&Local))
142 }
143 LocalResult::Ambiguous(dt1, _) => {
144 log::warn!(
145 "Ambiguous local time for {date_time} in {tzid}, picking earliest"
146 );
147 LooseDateTime::Local(dt1.with_timezone(&Local))
148 }
149 LocalResult::None => {
150 log::warn!(
151 "Invalid local time for {date_time} in {tzid}, falling back to floating"
152 );
153 LooseDateTime::Floating(date_time)
154 }
155 },
156 _ => {
157 log::warn!("Unknown timezone, treating as floating: {tzid}");
158 LooseDateTime::Floating(date_time)
159 }
160 },
161 },
162 DatePerhapsTime::Date(d) => LooseDateTime::DateOnly(d),
163 }
164 }
165}
166
167impl From<LooseDateTime> for DatePerhapsTime {
168 fn from(dt: LooseDateTime) -> Self {
169 use DatePerhapsTime::*;
170 match dt {
171 LooseDateTime::DateOnly(d) => Date(d),
172 LooseDateTime::Floating(dt) => DateTime(CalendarDateTime::Floating(dt)),
173 LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
174 Ok(tzid) => DateTime(CalendarDateTime::WithTimezone {
175 date_time: dt.naive_local(),
176 tzid,
177 }),
178 Err(_) => DateTime(CalendarDateTime::Utc(dt.into())),
179 },
180 }
181 }
182}
183
184impl From<NaiveDate> for LooseDateTime {
185 fn from(d: NaiveDate) -> Self {
186 LooseDateTime::DateOnly(d)
187 }
188}
189
190impl From<NaiveDateTime> for LooseDateTime {
191 fn from(dt: NaiveDateTime) -> Self {
192 LooseDateTime::Floating(dt)
193 }
194}
195
196impl From<DateTime<Local>> for LooseDateTime {
197 fn from(dt: DateTime<Local>) -> Self {
198 LooseDateTime::Local(dt)
199 }
200}
201
202impl From<DateTime<Utc>> for LooseDateTime {
203 fn from(dt: DateTime<Utc>) -> Self {
204 LooseDateTime::Local(dt.with_timezone(&Local))
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum RangePosition {
211 Before,
213
214 InRange,
216
217 After,
219
220 InvalidRange,
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use chrono::{NaiveDate, TimeZone};
228
229 #[test]
230 fn test_date_and_time_methods() {
231 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
232 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
233 let datetime = NaiveDateTime::new(date, time);
234 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
235
236 let d1 = LooseDateTime::DateOnly(date);
237 let d2 = LooseDateTime::Floating(datetime);
238 let d3 = LooseDateTime::Local(local_dt);
239
240 assert_eq!(d1.date(), date);
242 assert_eq!(d2.date(), date);
243 assert_eq!(d3.date(), date);
244
245 assert_eq!(d1.time(), None);
247 assert_eq!(d2.time(), Some(time));
248 assert_eq!(d3.time(), Some(time));
249 }
250
251 #[test]
252 fn test_with_start_of_day() {
253 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
254 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
255 let datetime = NaiveDateTime::new(date, time);
256 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
257
258 let d1 = LooseDateTime::DateOnly(date);
259 let d2 = LooseDateTime::Floating(datetime);
260 let d3 = LooseDateTime::Local(local_dt);
261
262 assert_eq!(
263 d1.with_start_of_day(),
264 NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
265 );
266 assert_eq!(d2.with_start_of_day(), datetime);
267 assert_eq!(d3.with_start_of_day(), datetime);
268 }
269
270 #[test]
271 fn test_with_end_of_day() {
272 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
273 let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
274 let datetime = NaiveDateTime::new(date, time);
275 let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
276
277 let d1 = LooseDateTime::DateOnly(date);
278 let d2 = LooseDateTime::Floating(datetime);
279 let d3 = LooseDateTime::Local(local_dt);
280
281 assert_eq!(
282 d1.with_end_of_day(),
283 NaiveDateTime::new(
284 date,
285 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
286 )
287 );
288 assert_eq!(d2.with_end_of_day(), datetime);
289 assert_eq!(d3.with_end_of_day(), datetime);
290 }
291
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 test_position_in_range_date_date() {
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 test_position_in_range_date_floating() {
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 test_position_in_range_floating_date() {
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 test_position_in_range_without_start() {
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 test_position_in_range_date_without_end() {
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 test_invalid_range() {
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 test_format_and_parse_stable() {
446 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
447 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
448 let datetime = NaiveDateTime::new(date, time);
449 let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
450
451 let d1 = LooseDateTime::DateOnly(date);
452 let d2 = LooseDateTime::Floating(datetime);
453 let d3 = LooseDateTime::Local(local);
454
455 let f1 = d1.format_stable();
457 let f2 = d2.format_stable();
458 let f3 = d3.format_stable();
459
460 assert_eq!(f1, "2024-07-18");
461 assert_eq!(f2, "2024-07-18T12:30:45");
462 assert!(f3.starts_with("2024-07-18T12:30:45"));
463
464 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
466 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
467 let parsed3 = LooseDateTime::parse_stable(&f3);
468 if let Some(LooseDateTime::Local(dt)) = parsed3 {
469 assert_eq!(dt.naive_local(), local.naive_local());
470 } else {
471 panic!("Failed to parse local datetime");
472 }
473 }
474}