1use std::ops::Add;
6
7use aimcal_ical::{self as ical, Segments};
8use jiff::civil::{self, Date, DateTime};
9use jiff::tz::TimeZone;
10use jiff::{Span, Zoned};
11
12use crate::RangePosition;
13use crate::datetime::util::{
14 STABLE_FORMAT_DATEONLY, STABLE_FORMAT_FLOATING, STABLE_FORMAT_LOCAL, end_of_day, start_of_day,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum LooseDateTime {
20 DateOnly(Date),
22 Floating(DateTime),
24 Local(Zoned),
27}
28
29impl LooseDateTime {
30 #[must_use]
32 pub fn date(&self) -> Date {
33 match self {
34 LooseDateTime::DateOnly(d) => *d,
35 LooseDateTime::Floating(dt) => dt.date(),
36 LooseDateTime::Local(zoned) => zoned.date(),
37 }
38 }
39
40 #[must_use]
42 pub fn time(&self) -> Option<civil::Time> {
43 match self {
44 LooseDateTime::DateOnly(_) => None,
45 LooseDateTime::Floating(dt) => Some(dt.time()),
46 LooseDateTime::Local(zoned) => Some(zoned.time()),
47 }
48 }
49
50 pub fn with_start_of_day(&self) -> DateTime {
52 let d = self.date();
53 let t = self.time().unwrap_or_else(start_of_day);
54 DateTime::from_parts(d, t)
55 }
56
57 pub fn with_end_of_day(&self) -> DateTime {
59 let d = self.date();
60 let t = self.time().unwrap_or_else(end_of_day);
61 DateTime::from_parts(d, t)
62 }
63
64 #[must_use]
66 pub fn position_in_range(
67 t: &DateTime,
68 start: &Option<LooseDateTime>,
69 end: &Option<LooseDateTime>,
70 ) -> RangePosition {
71 match (start, end) {
72 (Some(start), Some(end)) => {
73 let start_dt = start.with_start_of_day(); let end_dt = end.with_end_of_day(); if start_dt > end_dt {
76 RangePosition::InvalidRange
77 } else if t > &end_dt {
78 RangePosition::After
79 } else if t < &start_dt {
80 RangePosition::Before
81 } else {
82 RangePosition::InRange
83 }
84 }
85 (Some(start), None) => {
86 if t >= &start.with_start_of_day() {
87 RangePosition::InRange
88 } else {
89 RangePosition::Before
90 }
91 }
92 (None, Some(end)) => {
93 if t > &end.with_end_of_day() {
94 RangePosition::After
95 } else {
96 RangePosition::InRange
97 }
98 }
99 (None, None) => RangePosition::InvalidRange,
100 }
101 }
102
103 pub(crate) fn from_local_datetime(dt: DateTime) -> LooseDateTime {
105 let tz = TimeZone::system();
107 if let Ok(zoned) = dt.to_zoned(tz) {
108 LooseDateTime::Local(zoned)
109 } else {
110 tracing::warn!(
112 ?dt,
113 "failed to convert to local timezone, treating as floating"
114 );
115 LooseDateTime::Floating(dt)
116 }
117 }
118
119 pub(crate) fn format_stable(&self) -> String {
121 match self {
122 LooseDateTime::DateOnly(d) => d.strftime(STABLE_FORMAT_DATEONLY).to_string(),
123 LooseDateTime::Floating(dt) => dt.strftime(STABLE_FORMAT_FLOATING).to_string(),
124 LooseDateTime::Local(zoned) => zoned.strftime(STABLE_FORMAT_LOCAL).to_string(),
125 }
126 }
127
128 pub(crate) fn parse_stable(s: &str) -> Option<Self> {
129 match s.len() {
130 10 => Date::strptime(STABLE_FORMAT_DATEONLY, s)
132 .ok()
133 .map(Self::DateOnly),
134 19 => DateTime::strptime(STABLE_FORMAT_FLOATING, s)
136 .ok()
137 .map(Self::Floating),
138 20.. => Zoned::strptime(STABLE_FORMAT_LOCAL, s)
140 .ok()
141 .map(Self::Local),
142 _ => None,
143 }
144 }
145}
146
147impl From<ical::DateTimeProperty<Segments<'_>>> for LooseDateTime {
148 #[tracing::instrument]
149 fn from(dt: ical::DateTimeProperty<Segments<'_>>) -> Self {
150 let date = dt.date();
151 let time = dt.time();
152
153 if dt.is_date_only() {
154 LooseDateTime::DateOnly(date.into())
155 } else if dt.is_utc() {
156 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
157 LooseDateTime::Local(civil_dt.to_zoned(TimeZone::UTC).unwrap())
158 } else if dt.is_floating() {
159 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
160 LooseDateTime::Floating(civil_dt)
161 } else if dt.is_zoned() {
162 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
163 if let Some(tz_id) = &dt.tz_id {
164 let tz_id_str = tz_id.to_string();
165 if let Ok(tz) = TimeZone::get(tz_id_str.as_str()) {
166 if let Ok(zoned) = civil_dt.to_zoned(tz) {
167 LooseDateTime::Local(zoned)
168 } else {
169 tracing::warn!(tzid = %tz_id_str, "unknown timezone, treating as floating");
170 LooseDateTime::Floating(civil_dt)
171 }
172 } else {
173 tracing::warn!(tzid = %tz_id_str, "unknown timezone, treating as floating");
174 LooseDateTime::Floating(civil_dt)
175 }
176 } else {
177 tracing::warn!("zoned datetime without tz_id, treating as floating");
178 LooseDateTime::Floating(civil_dt)
179 }
180 } else {
181 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
183 LooseDateTime::Floating(civil_dt)
184 }
185 }
186}
187
188impl From<ical::DateTimeProperty<String>> for LooseDateTime {
189 fn from(dt: ical::DateTimeProperty<String>) -> Self {
190 let date = dt.date();
191 let time = dt.time();
192
193 if dt.is_date_only() {
194 LooseDateTime::DateOnly(date.into())
195 } else if dt.is_utc() {
196 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
197 LooseDateTime::Local(civil_dt.to_zoned(TimeZone::UTC).unwrap())
198 } else if dt.is_floating() {
199 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
200 LooseDateTime::Floating(civil_dt)
201 } else if dt.is_zoned() {
202 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
203 if let Some(tz_id) = &dt.tz_id {
204 if let Ok(tz) = TimeZone::get(tz_id.as_str()) {
205 if let Ok(zoned) = civil_dt.to_zoned(tz) {
206 LooseDateTime::Local(zoned)
207 } else {
208 tracing::warn!(tzid = %tz_id, "unknown timezone, treating as floating");
209 LooseDateTime::Floating(civil_dt)
210 }
211 } else {
212 tracing::warn!(tzid = %tz_id, "unknown timezone, treating as floating");
213 LooseDateTime::Floating(civil_dt)
214 }
215 } else {
216 tracing::warn!("zoned datetime without tz_id, treating as floating");
217 LooseDateTime::Floating(civil_dt)
218 }
219 } else {
220 let civil_dt = DateTime::from_parts(date.civil_date(), time.unwrap().civil_time());
222 LooseDateTime::Floating(civil_dt)
223 }
224 }
225}
226
227impl From<LooseDateTime> for ical::DateTimeProperty<String> {
228 fn from(dt: LooseDateTime) -> Self {
229 match dt {
230 LooseDateTime::DateOnly(d) => {
231 ical::DateTimeProperty::date_only(d.into(), Vec::new(), Vec::new(), ())
232 }
233 LooseDateTime::Floating(dt) => {
234 let date = dt.date().into();
235 let time = dt.time().into();
236 ical::DateTimeProperty::floating(date, time, Vec::new(), Vec::new(), ())
237 }
238 LooseDateTime::Local(zoned) => {
239 let tz = zoned.time_zone();
240 if *tz != TimeZone::UTC
241 && let Some(tz_name) = tz.iana_name()
242 {
243 let date = zoned.date().into();
244 let time = zoned.time().into();
245 ical::DateTimeProperty::zoned(
246 date,
247 time,
248 tz_name.to_string(),
249 Some(tz.clone()),
250 Vec::new(),
251 Vec::new(),
252 (),
253 )
254 } else {
255 let utc_dt = zoned.with_time_zone(TimeZone::UTC);
257 let date = utc_dt.date().into();
258 let time = utc_dt.time().into();
259 ical::DateTimeProperty::utc(date, time, Vec::new(), Vec::new(), ())
260 }
261 }
262 }
263 }
264}
265
266impl From<Date> for LooseDateTime {
267 fn from(d: Date) -> Self {
268 LooseDateTime::DateOnly(d)
269 }
270}
271
272impl From<DateTime> for LooseDateTime {
273 fn from(dt: DateTime) -> Self {
274 LooseDateTime::Floating(dt)
275 }
276}
277
278impl From<Zoned> for LooseDateTime {
279 fn from(zoned: Zoned) -> Self {
280 LooseDateTime::Local(zoned)
281 }
282}
283
284impl Add<Span> for LooseDateTime {
285 type Output = Self;
286
287 fn add(self, rhs: Span) -> Self::Output {
288 match self {
289 LooseDateTime::DateOnly(d) => LooseDateTime::DateOnly(d.checked_add(rhs).unwrap()),
290 LooseDateTime::Floating(dt) => LooseDateTime::Floating(dt.checked_add(rhs).unwrap()),
291 LooseDateTime::Local(zoned) => LooseDateTime::Local(zoned.checked_add(rhs).unwrap()),
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use jiff::Span;
299 use jiff::civil::{date, datetime, time};
300 use jiff::tz::TimeZone;
301
302 use super::*;
303
304 #[test]
305 fn provides_date_and_time_accessors() {
306 let date = date(2024, 7, 18);
307 let time = time(12, 30, 45, 0);
308 let datetime = datetime(2024, 7, 18, 12, 30, 45, 0);
309 let tz = TimeZone::UTC;
310 let zoned_dt = datetime.to_zoned(tz).unwrap();
311
312 let d1 = LooseDateTime::DateOnly(date);
313 let d2 = LooseDateTime::Floating(datetime);
314 let d3 = LooseDateTime::Local(zoned_dt);
315
316 assert_eq!(d1.date(), date);
318 assert_eq!(d2.date(), date);
319 assert_eq!(d3.date(), date);
320
321 assert_eq!(d1.time(), None);
323 assert_eq!(d2.time(), Some(time));
324 assert_eq!(d3.time(), Some(time));
325 }
326
327 #[test]
328 fn sets_time_to_start_of_day() {
329 let d = date(2024, 7, 18);
330 let t = time(12, 30, 0, 0);
331 let datetime = DateTime::from_parts(d, t);
332 let tz = TimeZone::UTC;
333 let zoned_dt = datetime.to_zoned(tz).unwrap();
334
335 let d1 = LooseDateTime::DateOnly(d);
336 let d2 = LooseDateTime::Floating(datetime);
337 let d3 = LooseDateTime::Local(zoned_dt);
338
339 assert_eq!(
340 d1.with_start_of_day(),
341 DateTime::from_parts(d, time(0, 0, 0, 0))
342 );
343 assert_eq!(d2.with_start_of_day(), datetime);
344 assert_eq!(d3.with_start_of_day(), datetime);
345 }
346
347 #[test]
348 fn sets_time_to_end_of_day() {
349 let d = date(2024, 7, 18);
350 let t = time(12, 30, 0, 0);
351 let datetime = DateTime::from_parts(d, t);
352 let tz = TimeZone::UTC;
353 let zoned_dt = datetime.to_zoned(tz).unwrap();
354
355 let d1 = LooseDateTime::DateOnly(d);
356 let d2 = LooseDateTime::Floating(datetime);
357 let d3 = LooseDateTime::Local(zoned_dt);
358
359 assert_eq!(
360 d1.with_end_of_day(),
361 DateTime::from_parts(d, time(23, 59, 59, 999_999_999))
362 );
363 assert_eq!(d2.with_end_of_day(), datetime);
364 assert_eq!(d3.with_end_of_day(), datetime);
365 }
366
367 #[test]
368 fn calculates_position_in_date_date_range() {
369 let start = LooseDateTime::DateOnly(date(2024, 1, 1));
370 let end = LooseDateTime::DateOnly(date(2024, 1, 3));
371
372 let t_before = datetime(2023, 12, 31, 23, 59, 59, 0);
373 let t_in_s = datetime(2024, 1, 1, 12, 0, 0, 0);
374 let t_in_e = datetime(2024, 1, 3, 12, 0, 0, 0);
375 let t_after = datetime(2024, 1, 4, 0, 0, 0, 0);
376
377 assert_eq!(
378 LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
379 RangePosition::Before
380 );
381 assert_eq!(
382 LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
383 RangePosition::InRange
384 );
385 assert_eq!(
386 LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
387 RangePosition::InRange
388 );
389 assert_eq!(
390 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
391 RangePosition::After
392 );
393 }
394
395 #[test]
396 fn calculates_position_in_date_floating_range() {
397 let start = LooseDateTime::DateOnly(date(2024, 1, 1));
398 let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0, 0));
399
400 let t_before = datetime(2023, 12, 31, 23, 59, 59, 0);
401 let t_in_s = datetime(2024, 1, 1, 12, 0, 0, 0);
402 let t_in_e = datetime(2024, 1, 3, 12, 0, 0, 0);
403 let t_after = datetime(2024, 1, 3, 14, 0, 0, 0);
404
405 assert_eq!(
406 LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
407 RangePosition::Before
408 );
409 assert_eq!(
410 LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
411 RangePosition::InRange
412 );
413 assert_eq!(
414 LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
415 RangePosition::InRange
416 );
417 assert_eq!(
418 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
419 RangePosition::After
420 );
421 }
422
423 #[test]
424 fn calculates_position_in_floating_date_range() {
425 let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0, 0));
426 let end = LooseDateTime::DateOnly(date(2024, 1, 1));
427
428 let t_before = datetime(2024, 1, 1, 12, 0, 0, 0);
429 let t_in_s = datetime(2024, 1, 1, 14, 0, 0, 0);
430 let t_in_e = datetime(2024, 1, 1, 23, 59, 59, 0);
431 let t_after = datetime(2024, 1, 2, 0, 0, 0, 0);
432
433 assert_eq!(
434 LooseDateTime::position_in_range(&t_before, &Some(start.clone()), &Some(end.clone())),
435 RangePosition::Before
436 );
437 assert_eq!(
438 LooseDateTime::position_in_range(&t_in_s, &Some(start.clone()), &Some(end.clone())),
439 RangePosition::InRange
440 );
441 assert_eq!(
442 LooseDateTime::position_in_range(&t_in_e, &Some(start.clone()), &Some(end.clone())),
443 RangePosition::InRange
444 );
445 assert_eq!(
446 LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
447 RangePosition::After
448 );
449 }
450
451 #[test]
452 fn calculates_position_with_end_only() {
453 let t1 = datetime(2023, 12, 31, 23, 59, 59, 0);
454 let t2 = datetime(2024, 1, 1, 20, 0, 0, 0);
455
456 for end in [
457 LooseDateTime::DateOnly(date(2023, 12, 31)),
458 LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59, 0)),
459 ] {
460 assert_eq!(
461 LooseDateTime::position_in_range(&t1, &None, &Some(end.clone())),
462 RangePosition::InRange,
463 "end = {end:?}"
464 );
465 assert_eq!(
466 LooseDateTime::position_in_range(&t2, &None, &Some(end.clone())),
467 RangePosition::After,
468 "end = {end:?}"
469 );
470 }
471 }
472
473 #[test]
474 fn calculates_position_with_start_only() {
475 let t1 = datetime(2023, 12, 31, 23, 59, 59, 0);
476 let t2 = datetime(2024, 1, 1, 0, 0, 0, 0);
477
478 for start in [
479 LooseDateTime::DateOnly(date(2024, 1, 1)),
480 LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0, 0)),
481 ] {
482 assert_eq!(
483 LooseDateTime::position_in_range(&t1, &Some(start.clone()), &None),
484 RangePosition::Before,
485 "start = {start:?}"
486 );
487 assert_eq!(
488 LooseDateTime::position_in_range(&t2, &Some(start.clone()), &None),
489 RangePosition::InRange,
490 "start = {start:?}"
491 );
492 }
493 }
494
495 #[test]
496 fn returns_invalid_range_for_inverted_or_missing_bounds() {
497 let start = LooseDateTime::DateOnly(date(2024, 1, 5));
498 let end = LooseDateTime::DateOnly(date(2024, 1, 1));
499
500 let t = datetime(2024, 1, 3, 12, 0, 0, 0);
501
502 assert_eq!(
503 LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
504 RangePosition::InvalidRange
505 );
506
507 assert_eq!(
508 LooseDateTime::position_in_range(&t, &None, &None),
509 RangePosition::InvalidRange
510 );
511 }
512
513 #[test]
514 fn creates_from_local_datetime() {
515 let date = date(2021, 1, 1);
517 let time = time(0, 0, 0, 0);
518 let datetime = DateTime::from_parts(date, time);
519 let loose_dt = LooseDateTime::from_local_datetime(datetime);
520
521 assert!(matches!(loose_dt, LooseDateTime::Local(_)));
523 }
524
525 #[test]
526 fn serializes_and_deserializes_stably() {
527 let date = date(2024, 7, 18);
528 let time = time(12, 30, 45, 0);
529 let datetime = DateTime::from_parts(date, time);
530 let tz = TimeZone::UTC;
531 let local = datetime.to_zoned(tz).unwrap();
532
533 let d1 = LooseDateTime::DateOnly(date);
534 let d2 = LooseDateTime::Floating(datetime);
535 let d3 = LooseDateTime::Local(local.clone());
536
537 let f1 = d1.format_stable();
539 let f2 = d2.format_stable();
540 let f3 = d3.format_stable();
541
542 assert_eq!(f1, "2024-07-18");
543 assert_eq!(f2, "2024-07-18T12:30:45");
544 assert!(f3.starts_with("2024-07-18T12:30:45"));
545
546 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
548 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
549 let parsed3 = LooseDateTime::parse_stable(&f3);
550 if let Some(LooseDateTime::Local(zoned)) = parsed3 {
551 assert_eq!(zoned.datetime(), local.datetime());
552 } else {
553 panic!("Failed to parse local datetime");
554 }
555 }
556
557 #[test]
558 fn adds_span_to_dateonly() {
559 let d = date(2025, 1, 1);
560 let added = LooseDateTime::DateOnly(d) + Span::new().days(2).hours(3);
561 let expected = LooseDateTime::DateOnly(date(2025, 1, 3));
562 assert_eq!(added, expected);
563 }
564
565 #[test]
566 fn adds_span_to_floating() {
567 let d = date(2025, 1, 1);
568 let t = time(12, 30, 45, 0);
569 let dt = LooseDateTime::Floating(DateTime::from_parts(d, t));
570 let added = dt + Span::new().days(2).hours(3);
571 let expected_date = date(2025, 1, 3);
572 let expected_time = time(15, 30, 45, 0);
573 let excepted = LooseDateTime::Floating(DateTime::from_parts(expected_date, expected_time));
574 assert_eq!(added, excepted);
575 }
576
577 #[test]
578 fn adds_span_to_local() {
579 let tz = TimeZone::UTC;
580 let d = date(2025, 1, 1);
581 let t = time(12, 30, 45, 0);
582 let datetime = DateTime::from_parts(d, t);
583 let zoned = datetime.to_zoned(tz.clone()).unwrap();
584 let added = LooseDateTime::Local(zoned.clone()) + Span::new().days(2).hours(3);
585 let expected_date = date(2025, 1, 3);
586 let expected_time = time(15, 30, 45, 0);
587 let expected_datetime = DateTime::from_parts(expected_date, expected_time);
588 let excepted = LooseDateTime::Local(expected_datetime.to_zoned(tz).unwrap());
589 assert_eq!(added, excepted);
590 }
591}