1use chrono::{
6 DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc, offset::LocalResult,
7};
8use chrono_tz::Tz;
9use icalendar::{CalendarDateTime, DatePerhapsTime};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum LooseDateTime {
14 DateOnly(NaiveDate),
16
17 Floating(NaiveDateTime),
19
20 Local(DateTime<Local>),
23}
24
25impl LooseDateTime {
26 pub fn date(&self) -> NaiveDate {
28 match self {
29 LooseDateTime::DateOnly(d) => *d,
30 LooseDateTime::Floating(dt) => dt.date(),
31 LooseDateTime::Local(dt) => dt.date_naive(),
32 }
33 }
34
35 pub fn time(&self) -> Option<NaiveTime> {
37 match self {
38 LooseDateTime::DateOnly(_) => None,
39 LooseDateTime::Floating(dt) => Some(dt.time()),
40 LooseDateTime::Local(dt) => Some(dt.time()),
41 }
42 }
43
44 pub fn with_start_of_day(&self) -> NaiveDateTime {
46 NaiveDateTime::new(
47 self.date(),
48 self.time()
49 .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
50 )
51 }
52
53 pub fn with_end_of_day(&self) -> NaiveDateTime {
55 NaiveDateTime::new(
56 self.date(),
57 self.time().unwrap_or_else(|| {
58 NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
60 }),
61 )
62 }
63
64 pub fn position_in_range(
66 t: &NaiveDateTime,
67 start: &LooseDateTime,
68 end: &Option<LooseDateTime>,
69 ) -> RangePosition {
70 let start_dt = start.with_start_of_day(); match end {
72 Some(end) => {
73 let end_dt = end.with_end_of_day(); if start_dt > end_dt {
75 RangePosition::InvalidRange
76 } else if t > &end_dt {
77 RangePosition::After
78 } else if t <= &start_dt {
79 RangePosition::Before
80 } else {
81 RangePosition::InRange
82 }
83 }
84 None => match &start_dt <= t {
85 true => RangePosition::InRange,
86 false => RangePosition::Before,
87 },
88 }
89 }
90
91 const DATEONLY_FORMAT: &str = "%Y-%m-%d";
93 const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
94 const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
95
96 pub(crate) fn format_stable(&self) -> String {
98 match self {
99 LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
100 LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
101 LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
102 }
103 }
104
105 pub(crate) fn parse_stable(s: &str) -> Option<Self> {
106 match s.len() {
107 10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
109 .map(Self::DateOnly)
110 .ok(),
111
112 19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
114 .map(Self::Floating)
115 .ok(),
116
117 20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
119 .map(|a| Self::Local(a.with_timezone(&Local)))
120 .ok(),
121
122 _ => None,
123 }
124 }
125}
126
127impl From<DatePerhapsTime> for LooseDateTime {
128 fn from(dt: DatePerhapsTime) -> Self {
129 match dt {
130 DatePerhapsTime::DateTime(dt) => match dt {
131 CalendarDateTime::Floating(dt) => LooseDateTime::Floating(dt),
132 CalendarDateTime::Utc(dt) => LooseDateTime::Local(dt.into()),
133 CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
134 Ok(tz) => match tz.from_local_datetime(&date_time) {
135 LocalResult::Single(dt_in_tz) => {
137 LooseDateTime::Local(dt_in_tz.with_timezone(&Local))
138 }
139 LocalResult::Ambiguous(dt1, _) => {
140 log::warn!(
141 "Ambiguous local time for {date_time} in {tzid}, picking earliest"
142 );
143 LooseDateTime::Local(dt1.with_timezone(&Local))
144 }
145 LocalResult::None => {
146 log::warn!(
147 "Invalid local time for {date_time} in {tzid}, falling back to floating"
148 );
149 LooseDateTime::Floating(date_time)
150 }
151 },
152 _ => {
153 log::warn!("Unknown timezone, treating as floating: {tzid}");
154 LooseDateTime::Floating(date_time)
155 }
156 },
157 },
158 DatePerhapsTime::Date(d) => LooseDateTime::DateOnly(d),
159 }
160 }
161}
162
163impl From<LooseDateTime> for DatePerhapsTime {
164 fn from(dt: LooseDateTime) -> Self {
165 use DatePerhapsTime::*;
166 match dt {
167 LooseDateTime::DateOnly(d) => Date(d),
168 LooseDateTime::Floating(dt) => DateTime(CalendarDateTime::Floating(dt)),
169 LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
170 Ok(tzid) => DateTime(CalendarDateTime::WithTimezone {
171 date_time: dt.naive_local(),
172 tzid,
173 }),
174 Err(_) => DateTime(CalendarDateTime::Utc(dt.into())),
175 },
176 }
177 }
178}
179
180impl From<NaiveDate> for LooseDateTime {
181 fn from(d: NaiveDate) -> Self {
182 LooseDateTime::DateOnly(d)
183 }
184}
185
186impl From<NaiveDateTime> for LooseDateTime {
187 fn from(dt: NaiveDateTime) -> Self {
188 LooseDateTime::Floating(dt)
189 }
190}
191
192impl From<DateTime<Local>> for LooseDateTime {
193 fn from(dt: DateTime<Local>) -> Self {
194 LooseDateTime::Local(dt)
195 }
196}
197
198impl From<DateTime<Utc>> for LooseDateTime {
199 fn from(dt: DateTime<Utc>) -> Self {
200 LooseDateTime::Local(dt.with_timezone(&Local))
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum RangePosition {
207 Before,
209
210 InRange,
212
213 After,
215
216 InvalidRange,
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use chrono::{NaiveDate, TimeZone};
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 #[test]
289 fn test_position_in_range_with_end() {
290 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
291 let end = Some(LooseDateTime::DateOnly(
292 NaiveDate::from_ymd_opt(2024, 1, 3).unwrap(),
293 ));
294
295 let t_before = NaiveDate::from_ymd_opt(2023, 12, 31)
296 .unwrap()
297 .and_hms_opt(23, 59, 59)
298 .unwrap();
299 let t_in = NaiveDate::from_ymd_opt(2024, 1, 2)
300 .unwrap()
301 .and_hms_opt(12, 0, 0)
302 .unwrap();
303 let t_after = NaiveDate::from_ymd_opt(2024, 1, 4)
304 .unwrap()
305 .and_hms_opt(0, 0, 0)
306 .unwrap();
307
308 assert_eq!(
309 LooseDateTime::position_in_range(&t_before, &start, &end),
310 RangePosition::Before
311 );
312 assert_eq!(
313 LooseDateTime::position_in_range(&t_in, &start, &end),
314 RangePosition::InRange
315 );
316 assert_eq!(
317 LooseDateTime::position_in_range(&t_after, &start, &end),
318 RangePosition::After
319 );
320 }
321
322 #[test]
323 fn test_position_in_range_without_end() {
324 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
325
326 let t1 = NaiveDate::from_ymd_opt(2023, 12, 31)
327 .unwrap()
328 .and_hms_opt(23, 59, 59)
329 .unwrap();
330 let t2 = NaiveDate::from_ymd_opt(2024, 1, 1)
331 .unwrap()
332 .and_hms_opt(0, 0, 0)
333 .unwrap();
334
335 assert_eq!(
336 LooseDateTime::position_in_range(&t1, &start, &None),
337 RangePosition::Before
338 );
339 assert_eq!(
340 LooseDateTime::position_in_range(&t2, &start, &None),
341 RangePosition::InRange
342 );
343 }
344
345 #[test]
346 fn test_invalid_range() {
347 let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
348 let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
349 let t = NaiveDate::from_ymd_opt(2024, 1, 3)
350 .unwrap()
351 .and_hms_opt(12, 0, 0)
352 .unwrap();
353
354 assert_eq!(
355 LooseDateTime::position_in_range(&t, &start, &Some(end)),
356 RangePosition::InvalidRange
357 );
358 }
359
360 #[test]
361 fn test_format_and_parse_stable() {
362 let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
363 let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
364 let datetime = NaiveDateTime::new(date, time);
365 let local = TimeZone::with_ymd_and_hms(&Local, 2024, 7, 18, 12, 30, 45).unwrap();
366
367 let d1 = LooseDateTime::DateOnly(date);
368 let d2 = LooseDateTime::Floating(datetime);
369 let d3 = LooseDateTime::Local(local);
370
371 let f1 = d1.format_stable();
373 let f2 = d2.format_stable();
374 let f3 = d3.format_stable();
375
376 assert_eq!(f1, "2024-07-18");
377 assert_eq!(f2, "2024-07-18T12:30:45");
378 assert!(f3.starts_with("2024-07-18T12:30:45"));
379
380 assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
382 assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
383 let parsed3 = LooseDateTime::parse_stable(&f3);
384 if let Some(LooseDateTime::Local(dt)) = parsed3 {
385 assert_eq!(dt.naive_local(), local.naive_local());
386 } else {
387 panic!("Failed to parse local datetime");
388 }
389 }
390}