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