1use std::{str::FromStr, sync::OnceLock};
6
7use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, TimeDelta, TimeZone, Timelike};
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 if n == 0 {
114 next_suggested_time(now)
115 } else {
116 let date = now.date_naive() + TimeDelta::days(n);
117 let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
118 LooseDateTime::from_local_datetime(dt)
119 }
120 }
121 DateTimeAnchor::DateTime(dt) => dt,
122 DateTimeAnchor::Time(t) => {
123 let date = now.date_naive();
124 let delta = if now.time() <= t {
126 TimeDelta::zero()
127 } else {
128 TimeDelta::days(1)
129 };
130 let dt = NaiveDateTime::new(date, t) + delta;
131 LooseDateTime::from_local_datetime(dt)
132 }
133 }
134 }
135}
136
137impl FromStr for DateTimeAnchor {
138 type Err = String;
139
140 fn from_str(t: &str) -> Result<Self, Self::Err> {
141 match t {
143 "yesterday" => return Ok(Self::yesterday()),
144 "tomorrow" => return Ok(Self::tomorrow()),
145 "today" => return Ok(Self::today()),
146 "now" => return Ok(Self::now()),
147 _ => {}
148 }
149
150 if let Ok(dt) = NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M") {
151 Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)))
153 } else if let Ok(time) = NaiveTime::parse_from_str(t, "%H:%M") {
154 Ok(Self::Time(time))
156 } else if let Some(hours) = parse_hours(t) {
157 Ok(Self::InHours(hours))
159 } else if let Some(days) = parse_days(t) {
160 Ok(Self::InDays(days))
162 } else {
163 Err(format!("Invalid timedelta format: {t}"))
164 }
165 }
166}
167
168fn parse_hours(s: &str) -> Option<i64> {
170 const RE: &str = r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$";
171 static REGEX: OnceLock<Regex> = OnceLock::new();
172 let re = REGEX.get_or_init(|| Regex::new(RE).unwrap());
173 if let Some(captures) = re.captures(s)
174 && let Ok(num) = captures[1].parse::<i64>()
175 {
176 return Some(num);
177 }
178
179 None
180}
181
182fn parse_days(s: &str) -> Option<i64> {
184 const RE: &str = r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$";
185 static REGEX: OnceLock<Regex> = OnceLock::new();
186 let re = REGEX.get_or_init(|| Regex::new(RE).unwrap());
187 if let Some(captures) = re.captures(s)
188 && let Ok(num) = captures[1].parse::<i64>()
189 {
190 return Some(num);
191 }
192
193 None
194}
195
196fn next_suggested_time<Tz: TimeZone>(now: &DateTime<Tz>) -> LooseDateTime {
197 let date = now.date_naive();
198 let current_hour = now.hour();
199 for hour in [9, 13, 18] {
200 if current_hour < hour {
201 let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(hour, 0, 0).unwrap());
202 return LooseDateTime::from_local_datetime(dt);
203 }
204 }
205
206 LooseDateTime::DateOnly(date)
207}
208
209#[cfg(test)]
210mod tests {
211 use chrono::{NaiveDate, Utc};
212
213 use super::*;
214
215 #[test]
216 fn test_anchor_now() {
217 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
218 assert_eq!(DateTimeAnchor::now().parse_as_start_of_day(&now), now);
219 assert_eq!(DateTimeAnchor::now().parse_as_end_of_day(&now), now);
220 }
221
222 #[test]
223 fn test_anchor_in_hours() {
224 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
225 let anchor = DateTimeAnchor::InHours(1);
226
227 let parsed = anchor.parse_as_start_of_day(&now);
228 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 16, 30, 45).unwrap();
229 assert_eq!(parsed, expected);
230
231 let parsed = anchor.parse_as_end_of_day(&now);
232 assert_eq!(parsed, expected);
233 }
234
235 #[test]
236 fn test_anchor_in_days() {
237 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
238 let anchor = DateTimeAnchor::InDays(1);
239
240 let parsed = anchor.parse_as_start_of_day(&now);
241 let expected = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
242 assert_eq!(parsed, expected);
243
244 let parsed = anchor.parse_as_end_of_day(&now);
245 assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
246 assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
247 }
248
249 #[test]
250 fn test_anchor_time_dateonly() {
251 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
252 let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
253 let loose_date = LooseDateTime::DateOnly(date);
254 let anchor = DateTimeAnchor::DateTime(loose_date);
255
256 let parsed = anchor.parse_as_start_of_day(&now);
257 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
258 assert_eq!(parsed, expected);
259
260 let parsed = anchor.parse_as_end_of_day(&now);
261 assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 5, 23, 59, 59).unwrap());
262 assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 6, 0, 0, 0).unwrap());
263 }
264
265 #[test]
266 fn test_anchor_time_floating() {
267 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
268 let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
269 let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
270 let datetime = NaiveDateTime::new(date, time);
271 let loose_datetime = LooseDateTime::Floating(datetime);
272 let anchor = DateTimeAnchor::DateTime(loose_datetime);
273
274 let parsed = anchor.parse_as_start_of_day(&now);
275 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
276 assert_eq!(parsed, expected);
277
278 let parsed = anchor.parse_as_end_of_day(&now);
279 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
280 assert_eq!(parsed, expected);
281 }
282
283 #[test]
284 fn test_anchor_time_local() {
285 let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
286 let local_dt = Local.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
287 let loose_local = LooseDateTime::Local(local_dt);
288 let anchor = DateTimeAnchor::DateTime(loose_local);
289
290 let parsed = anchor.parse_as_start_of_day(&now);
291 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
292 assert_eq!(parsed, expected);
293
294 let parsed = anchor.parse_as_end_of_day(&now);
295 let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
296 assert_eq!(parsed, expected);
297 }
298
299 #[test]
300 fn test_start_of_day() {
301 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 59).unwrap();
302 let parsed = start_of_day(&now);
303 let expected = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
304 assert_eq!(parsed, expected);
305 }
306
307 #[test]
308 fn test_end_of_day() {
309 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
310 let parsed = end_of_day(&now);
311 let last_sec = Utc.with_ymd_and_hms(2025, 1, 1, 23, 59, 59).unwrap();
312 let next_day = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
313 assert!(parsed > last_sec);
314 assert!(parsed < next_day);
315 }
316
317 #[test]
318 fn test_from_local_datetime_dst_ambiguity_pick_earliest() {
319 let tz = chrono_tz::America::New_York; let now = NaiveDateTime::new(
321 NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
322 NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
323 );
324
325 let parsed = from_local_datetime(&tz, now).with_timezone(&Utc);
326 let expected = Utc.with_ymd_and_hms(2025, 11, 2, 5, 30, 0).unwrap();
327 assert_eq!(parsed, expected);
328 }
329
330 #[test]
331 fn test_time_parsing() {
332 let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
334 let anchor = DateTimeAnchor::Time(time);
335
336 let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
338 let parsed_start = anchor.parse_as_start_of_day(&now);
339 let parsed_end = anchor.parse_as_end_of_day(&now);
340
341 assert_eq!(parsed_start.date_naive(), now.date_naive());
343 assert_eq!(parsed_start.time(), time);
344 assert_eq!(parsed_end.date_naive(), now.date_naive());
345 assert_eq!(parsed_end.time(), time);
346 }
347
348 #[test]
349 fn test_parse_from_loose_in_days() {
350 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap());
351 let anchor = DateTimeAnchor::DateTime(expected);
352 let result = anchor.parse_from_loose(&expected);
353 assert_eq!(result, expected);
354 }
355
356 #[test]
357 fn test_parse_from_loose_in_hours() {
358 let anchor = DateTimeAnchor::InHours(3);
359 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
360 let result = anchor.parse_from_loose(&now.into());
361 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap());
362 assert_eq!(result, expected);
363 }
364
365 #[test]
366 fn test_parse_from_loose_datetime() {
367 let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
368 Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
369 ));
370 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
371 let result = anchor.parse_from_loose(&now.into());
372 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
373 assert_eq!(result, expected);
374 }
375
376 #[test]
377 fn test_parse_from_loose_time() {
378 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
379 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
380 let result = anchor.parse_from_loose(&now.into());
381 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
382 assert_eq!(result, expected);
383 }
384
385 #[test]
386 fn test_parse_from_dt_in_days() {
387 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
388 let expected = now.into();
389 let anchor = DateTimeAnchor::DateTime(expected);
390 let result = anchor.parse_from_dt(&now);
391 assert_eq!(result, expected);
392 }
393
394 #[test]
395 fn test_parse_from_dt_in_hours() {
396 let anchor = DateTimeAnchor::InHours(3);
397 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
398 let result = anchor.parse_from_dt(&now);
399 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap());
400 assert_eq!(result, expected);
401 }
402
403 #[test]
404 fn test_parse_from_dt_datetime() {
405 let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
406 Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
407 ));
408 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
409 let result = anchor.parse_from_dt(&now);
410 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
411 assert_eq!(result, expected);
412 }
413
414 #[test]
415 fn test_parse_from_dt_time_before_now() {
416 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
418 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
419 let result = anchor.parse_from_dt(&now);
420 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 2, 10, 0, 0).unwrap());
421 assert_eq!(result, expected);
422 }
423
424 #[test]
425 fn test_parse_from_dt_time_after_now() {
426 let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 0, 0).unwrap());
428 let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
429 let result = anchor.parse_from_dt(&now);
430 let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap());
431 assert_eq!(result, expected);
432 }
433
434 #[test]
435 fn test_from_str_keywords() {
436 for (s, expected) in [
437 ("now", DateTimeAnchor::now()),
438 ("today", DateTimeAnchor::today()),
439 ("yesterday", DateTimeAnchor::yesterday()),
440 ("tomorrow", DateTimeAnchor::tomorrow()),
441 ] {
442 let anchor = DateTimeAnchor::from_str(s).unwrap();
443 assert_eq!(anchor, expected);
444 }
445 }
446
447 #[test]
448 fn test_from_str_datetime() {
449 let anchor = DateTimeAnchor::from_str("2025-01-15 14:30").unwrap();
450 let expected = DateTimeAnchor::DateTime(LooseDateTime::Local(
451 Local.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap(),
452 ));
453 assert_eq!(anchor, expected);
454 }
455
456 #[test]
457 fn test_from_str_time() {
458 let anchor = DateTimeAnchor::from_str("14:30").unwrap();
459 let expected = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 30, 0).unwrap());
460 assert_eq!(anchor, expected);
461 }
462
463 #[test]
464 fn test_from_str_invalid() {
465 let result = DateTimeAnchor::from_str("invalid");
466 assert!(result.is_err());
467 assert!(result.unwrap_err().contains("Invalid timedelta format"));
468 }
469
470 #[test]
471 fn test_from_str_hours() {
472 for s in [
473 "in 10hours",
474 "in 10H",
475 " IN 10 hours ",
476 "10hours",
477 "10 HOURS",
478 " 10 hours ",
479 "10h",
480 "10 H",
481 " 10 h ",
482 ] {
483 let anchor = DateTimeAnchor::from_str(s).unwrap();
484 let expected = DateTimeAnchor::InHours(10);
485 assert_eq!(anchor, expected, "Failed to parse '{}'", s);
486 }
487 }
488
489 #[test]
490 fn test_from_str_days() {
491 for s in [
492 "in 10days",
493 "in 10D",
494 " IN 10 days ",
495 "10days",
496 "10 DAYS",
497 " 10 days ",
498 "10d",
499 "10 D",
500 " 10 d ",
501 ] {
502 let anchor = DateTimeAnchor::from_str(s).unwrap();
503 let expected = DateTimeAnchor::InDays(10);
504 assert_eq!(anchor, expected, "Failed to parse '{}'", s);
505 }
506 }
507
508 #[test]
509 fn test_next_suggested_time() {
510 let test_cases = vec![
511 (8, 30, 9, "Before 9 AM, should suggest 9 AM"),
513 (
514 10,
515 30,
516 13,
517 "After 9 AM but before 1 PM, should suggest 1 PM",
518 ),
519 (
520 14,
521 30,
522 18,
523 "After 1 PM but before 6 PM, should suggest 6 PM",
524 ),
525 (9, 0, 13, "Exactly at 9 AM, should suggest 1 PM"),
526 (13, 0, 18, "Exactly at 1 PM, should suggest 6 PM"),
527 ];
528
529 for (hour, min, expected_hour, description) in test_cases {
530 let now = Local.with_ymd_and_hms(2025, 1, 1, hour, min, 0).unwrap();
531 let result = next_suggested_time(&now);
532 let expected = LooseDateTime::Local(
533 Local
534 .with_ymd_and_hms(2025, 1, 1, expected_hour, 0, 0)
535 .unwrap(),
536 );
537 assert_eq!(result, expected, "{}", description);
538 }
539 }
540
541 #[test]
542 fn test_next_suggested_time_after_6pm() {
543 let now = Local.with_ymd_and_hms(2025, 1, 1, 19, 30, 0).unwrap();
545 let result = next_suggested_time(&now);
546 let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
547 assert_eq!(result, expected, "After 6 PM, should suggest DateOnly");
548
549 let now = Local.with_ymd_and_hms(2025, 1, 1, 18, 0, 0).unwrap();
551 let result = next_suggested_time(&now);
552 let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
553 assert_eq!(result, expected, "Exactly at 6 PM, should suggest DateOnly");
554 }
555}