1use std::{str::FromStr, sync::OnceLock};
6
7use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, TimeDelta, TimeZone};
8use regex::Regex;
9
10use crate::LooseDateTime;
11use crate::datetime::util::{end_of_day, from_local_datetime, start_of_day};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DateTimeAnchor {
16 InHours(i64),
18
19 InDays(i64),
21
22 DateTime(LooseDateTime),
24
25 Time(NaiveTime),
27}
28
29impl DateTimeAnchor {
30 pub fn now() -> Self {
32 DateTimeAnchor::InHours(0)
33 }
34
35 pub fn today() -> Self {
37 DateTimeAnchor::InDays(0)
38 }
39
40 pub fn tomorrow() -> Self {
42 DateTimeAnchor::InDays(1)
43 }
44
45 pub fn yesterday() -> Self {
47 DateTimeAnchor::InDays(-1)
48 }
49
50 pub fn parse_as_start_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
52 match self {
53 DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
54 DateTimeAnchor::InDays(n) => start_of_day(now) + TimeDelta::days(*n),
55 DateTimeAnchor::DateTime(t) => {
56 let naive = t.with_start_of_day();
57 from_local_datetime(&now.timezone(), naive)
58 }
59 DateTimeAnchor::Time(t) => {
60 let naive = NaiveDateTime::new(now.date_naive(), *t);
61 from_local_datetime(&now.timezone(), naive)
62 }
63 }
64 }
65
66 pub fn parse_as_end_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
68 match self {
69 DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
70 DateTimeAnchor::InDays(n) => end_of_day(now) + TimeDelta::days(*n),
71 DateTimeAnchor::DateTime(dt) => {
72 let naive = dt.with_end_of_day();
73 from_local_datetime(&now.timezone(), naive)
74 }
75 DateTimeAnchor::Time(t) => {
76 let naive = NaiveDateTime::new(now.date_naive(), *t);
77 from_local_datetime(&now.timezone(), naive)
78 }
79 }
80 }
81
82 pub fn parse_from_loose(self, now: &LooseDateTime) -> LooseDateTime {
84 match self {
85 DateTimeAnchor::InHours(n) => *now + TimeDelta::hours(n),
86 DateTimeAnchor::InDays(n) => *now + TimeDelta::days(n),
87 DateTimeAnchor::DateTime(dt) => dt,
88 DateTimeAnchor::Time(t) => match now {
89 LooseDateTime::Local(dt) => {
90 let dt = NaiveDateTime::new(dt.date_naive(), t);
91 from_local_datetime(&Local, dt).into()
92 }
93 LooseDateTime::Floating(dt) => {
94 let dt = NaiveDateTime::new(dt.date(), t);
95 LooseDateTime::Floating(dt)
96 }
97 LooseDateTime::DateOnly(date) => {
98 let dt = NaiveDateTime::new(*date, t);
99 LooseDateTime::from_local_datetime(dt)
100 }
101 },
102 }
103 }
104
105 pub fn parse_from_dt<Tz: TimeZone>(self, now: &DateTime<Tz>) -> LooseDateTime {
107 match self {
108 DateTimeAnchor::InHours(n) => {
109 let dt = now.clone() + TimeDelta::hours(n);
110 LooseDateTime::Local(dt.with_timezone(&Local))
111 }
112 DateTimeAnchor::InDays(n) => {
113 let date = now.date_naive() + TimeDelta::days(n);
114 let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
115 LooseDateTime::from_local_datetime(dt)
116 }
117 DateTimeAnchor::DateTime(dt) => dt,
118 DateTimeAnchor::Time(t) => {
119 let date = now.date_naive();
120 let delta = if now.time() <= t {
122 TimeDelta::zero()
123 } else {
124 TimeDelta::days(1)
125 };
126 let dt = NaiveDateTime::new(date, t) + delta;
127 LooseDateTime::from_local_datetime(dt)
128 }
129 }
130 }
131}
132
133impl FromStr for DateTimeAnchor {
134 type Err = String;
135
136 fn from_str(t: &str) -> Result<Self, Self::Err> {
137 match t {
139 "yesterday" => return Ok(Self::yesterday()),
140 "tomorrow" => return Ok(Self::tomorrow()),
141 "today" => return Ok(Self::today()),
142 "now" => return Ok(Self::now()),
143 _ => {}
144 }
145
146 if let Ok(dt) = NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M") {
147 Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)))
149 } else if let Ok(time) = NaiveTime::parse_from_str(t, "%H:%M") {
150 Ok(Self::Time(time))
152 } else if let Some(hours) = parse_hours(t) {
153 Ok(Self::InHours(hours))
155 } else if let Some(days) = parse_days(t) {
156 Ok(Self::InDays(days))
158 } else {
159 Err(format!("Invalid timedelta format: {t}"))
160 }
161 }
162}
163
164fn parse_hours(s: &str) -> Option<i64> {
166 const RE: &str = r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$";
167 static REGEX: OnceLock<Regex> = OnceLock::new();
168 let re = REGEX.get_or_init(|| Regex::new(RE).unwrap());
169 if let Some(captures) = re.captures(s)
170 && let Ok(num) = captures[1].parse::<i64>()
171 {
172 return Some(num);
173 }
174
175 None
176}
177
178fn parse_days(s: &str) -> Option<i64> {
180 const RE: &str = r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$";
181 static REGEX: OnceLock<Regex> = OnceLock::new();
182 let re = REGEX.get_or_init(|| Regex::new(RE).unwrap());
183 if let Some(captures) = re.captures(s)
184 && let Ok(num) = captures[1].parse::<i64>()
185 {
186 return Some(num);
187 }
188
189 None
190}
191
192#[cfg(test)]
193mod tests {
194 use chrono::{NaiveDate, Utc};
195
196 use super::*;
197
198 #[test]
199 fn test_anchor_now() {
200 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
201 assert_eq!(DateTimeAnchor::now().parse_as_start_of_day(&now), now);
202 assert_eq!(DateTimeAnchor::now().parse_as_end_of_day(&now), now);
203 }
204
205 #[test]
206 fn test_anchor_in_hours() {
207 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
208 let anchor = DateTimeAnchor::InHours(1);
209
210 let parsed = anchor.parse_as_start_of_day(&now);
211 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 16, 30, 45).unwrap();
212 assert_eq!(parsed, expected);
213
214 let parsed = anchor.parse_as_end_of_day(&now);
215 assert_eq!(parsed, expected);
216 }
217
218 #[test]
219 fn test_anchor_in_days() {
220 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
221 let anchor = DateTimeAnchor::InDays(1);
222
223 let parsed = anchor.parse_as_start_of_day(&now);
224 let expected = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
225 assert_eq!(parsed, expected);
226
227 let parsed = anchor.parse_as_end_of_day(&now);
228 assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
229 assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
230 }
231
232 #[test]
233 fn test_anchor_time_dateonly() {
234 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
235 let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
236 let loose_date = LooseDateTime::DateOnly(date);
237 let anchor = DateTimeAnchor::DateTime(loose_date);
238
239 let parsed = anchor.parse_as_start_of_day(&now);
240 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
241 assert_eq!(parsed, expected);
242
243 let parsed = anchor.parse_as_end_of_day(&now);
244 assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 5, 23, 59, 59).unwrap());
245 assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 6, 0, 0, 0).unwrap());
246 }
247
248 #[test]
249 fn test_anchor_time_floating() {
250 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
251 let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
252 let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
253 let datetime = NaiveDateTime::new(date, time);
254 let loose_datetime = LooseDateTime::Floating(datetime);
255 let anchor = DateTimeAnchor::DateTime(loose_datetime);
256
257 let parsed = anchor.parse_as_start_of_day(&now);
258 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
259 assert_eq!(parsed, expected);
260
261 let parsed = anchor.parse_as_end_of_day(&now);
262 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
263 assert_eq!(parsed, expected);
264 }
265
266 #[test]
267 fn test_anchor_time_local() {
268 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
269 let local_dt = Local.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
270 let loose_local = LooseDateTime::Local(local_dt);
271 let anchor = DateTimeAnchor::DateTime(loose_local);
272
273 let parsed = anchor.parse_as_start_of_day(&now);
274 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
275 assert_eq!(parsed, expected);
276
277 let parsed = anchor.parse_as_end_of_day(&now);
278 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
279 assert_eq!(parsed, expected);
280 }
281
282 #[test]
283 fn test_start_of_day() {
284 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 59).unwrap();
285 let parsed = start_of_day(&now);
286 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
287 assert_eq!(parsed, expected);
288 }
289
290 #[test]
291 fn test_end_of_day() {
292 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
293 let parsed = end_of_day(&now);
294 let last_sec = Utc.with_ymd_and_hms(2025, 1, 1, 23, 59, 59).unwrap();
295 let next_day = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
296 assert!(parsed > last_sec);
297 assert!(parsed < next_day);
298 }
299
300 #[test]
301 fn test_from_local_datetime_dst_ambiguity_pick_earliest() {
302 let tz = chrono_tz::America::New_York; let now = NaiveDateTime::new(
304 NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
305 NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
306 );
307
308 let parsed = from_local_datetime(&tz, now).with_timezone(&Utc);
309 let expected = Utc.with_ymd_and_hms(2025, 11, 2, 5, 30, 0).unwrap();
310 assert_eq!(parsed, expected);
311 }
312
313 #[test]
314 fn test_time_parsing() {
315 let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
317 let anchor = DateTimeAnchor::Time(time);
318
319 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
321 let parsed_start = anchor.parse_as_start_of_day(&now);
322 let parsed_end = anchor.parse_as_end_of_day(&now);
323
324 assert_eq!(parsed_start.date_naive(), now.date_naive());
326 assert_eq!(parsed_start.time(), time);
327 assert_eq!(parsed_end.date_naive(), now.date_naive());
328 assert_eq!(parsed_end.time(), time);
329 }
330
331 #[test]
332 fn test_parse_from_loose_in_days() {
333 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap());
334 let anchor = DateTimeAnchor::DateTime(expected);
335 let result = anchor.parse_from_loose(&expected);
336 assert_eq!(result, expected);
337 }
338
339 #[test]
340 fn test_parse_from_loose_in_hours() {
341 let anchor = DateTimeAnchor::InHours(3);
342 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
343 let result = anchor.parse_from_loose(&now.into());
344 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap());
345 assert_eq!(result, expected);
346 }
347
348 #[test]
349 fn test_parse_from_loose_datetime() {
350 let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
351 Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
352 ));
353 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
354 let result = anchor.parse_from_loose(&now.into());
355 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
356 assert_eq!(result, expected);
357 }
358
359 #[test]
360 fn test_parse_from_loose_time() {
361 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
362 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
363 let result = anchor.parse_from_loose(&now.into());
364 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
365 assert_eq!(result, expected);
366 }
367
368 #[test]
369 fn test_parse_from_dt_in_days() {
370 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
371 let expected = now.into();
372 let anchor = DateTimeAnchor::DateTime(expected);
373 let result = anchor.parse_from_dt(&now);
374 assert_eq!(result, expected);
375 }
376
377 #[test]
378 fn test_parse_from_dt_in_hours() {
379 let anchor = DateTimeAnchor::InHours(3);
380 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
381 let result = anchor.parse_from_dt(&now);
382 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap());
383 assert_eq!(result, expected);
384 }
385
386 #[test]
387 fn test_parse_from_dt_datetime() {
388 let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
389 Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
390 ));
391 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
392 let result = anchor.parse_from_dt(&now);
393 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
394 assert_eq!(result, expected);
395 }
396
397 #[test]
398 fn test_parse_from_dt_time_before_now() {
399 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
401 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
402 let result = anchor.parse_from_dt(&now);
403 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 2, 10, 0, 0).unwrap());
404 assert_eq!(result, expected);
405 }
406
407 #[test]
408 fn test_parse_from_dt_time_after_now() {
409 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 0, 0).unwrap());
411 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
412 let result = anchor.parse_from_dt(&now);
413 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap());
414 assert_eq!(result, expected);
415 }
416
417 #[test]
418 fn test_from_str_keywords() {
419 for (s, expected) in [
420 ("now", DateTimeAnchor::now()),
421 ("today", DateTimeAnchor::today()),
422 ("yesterday", DateTimeAnchor::yesterday()),
423 ("tomorrow", DateTimeAnchor::tomorrow()),
424 ] {
425 let anchor = DateTimeAnchor::from_str(s).unwrap();
426 assert_eq!(anchor, expected);
427 }
428 }
429
430 #[test]
431 fn test_from_str_datetime() {
432 let anchor = DateTimeAnchor::from_str("2025-01-15 14:30").unwrap();
433 let expected = DateTimeAnchor::DateTime(LooseDateTime::Local(
434 Local.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap(),
435 ));
436 assert_eq!(anchor, expected);
437 }
438
439 #[test]
440 fn test_from_str_time() {
441 let anchor = DateTimeAnchor::from_str("14:30").unwrap();
442 let expected = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 30, 0).unwrap());
443 assert_eq!(anchor, expected);
444 }
445
446 #[test]
447 fn test_from_str_invalid() {
448 let result = DateTimeAnchor::from_str("invalid");
449 assert!(result.is_err());
450 assert!(result.unwrap_err().contains("Invalid timedelta format"));
451 }
452
453 #[test]
454 fn test_from_str_hours() {
455 for s in [
456 "in 10hours",
457 "in 10H",
458 " IN 10 hours ",
459 "10hours",
460 "10 HOURS",
461 " 10 hours ",
462 "10h",
463 "10 H",
464 " 10 h ",
465 ] {
466 let anchor = DateTimeAnchor::from_str(s).unwrap();
467 let expected = DateTimeAnchor::InHours(10);
468 assert_eq!(anchor, expected, "Failed to parse '{}'", s);
469 }
470 }
471
472 #[test]
473 fn test_from_str_days() {
474 for s in [
475 "in 10days",
476 "in 10D",
477 " IN 10 days ",
478 "10days",
479 "10 DAYS",
480 " 10 days ",
481 "10d",
482 "10 D",
483 " 10 d ",
484 ] {
485 let anchor = DateTimeAnchor::from_str(s).unwrap();
486 let expected = DateTimeAnchor::InDays(10);
487 assert_eq!(anchor, expected, "Failed to parse '{}'", s);
488 }
489 }
490}