aimcal_core/datetime/
anchor.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use 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/// Represents a date and time anchor that can be used to calculate relative dates and times.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DateTimeAnchor {
16    /// A specific number of hours in the future or past.
17    InHours(i64),
18
19    /// A specific number of days in the future or past.
20    InDays(i64),
21
22    /// A specific date and time.
23    DateTime(LooseDateTime),
24
25    /// A specific time.
26    Time(NaiveTime),
27}
28
29impl DateTimeAnchor {
30    /// Represents the current time.
31    pub fn now() -> Self {
32        DateTimeAnchor::InHours(0)
33    }
34
35    /// Represents the current date.
36    pub fn today() -> Self {
37        DateTimeAnchor::InDays(0)
38    }
39
40    /// Represents tomorrow, which is one day after today.
41    pub fn tomorrow() -> Self {
42        DateTimeAnchor::InDays(1)
43    }
44
45    /// Represents yesterday, which is one day before today.
46    pub fn yesterday() -> Self {
47        DateTimeAnchor::InDays(-1)
48    }
49
50    /// Parses the `DateTimeAnchor` enum based on the current time.
51    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    /// Parses the `DateTimeAnchor` enum based on the current time.
67    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    /// Parses the `DateTimeAnchor` to a `LooseDateTime` based on the provided current local time.
83    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    /// Parses the `DateTimeAnchor` to a `LooseDateTime` based on the provided current time in any timezone.
106    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                // If the time has already passed today, use tomorrow
125                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        // Handle keywords
142        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            // Parse as datetime
152            Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)))
153        } else if let Ok(time) = NaiveTime::parse_from_str(t, "%H:%M") {
154            // Parse as time only
155            Ok(Self::Time(time))
156        } else if let Some(hours) = parse_hours(t) {
157            // Parse as hours (e.g., "10h", "10 hours", "10hours", "in 10hours")
158            Ok(Self::InHours(hours))
159        } else if let Some(days) = parse_days(t) {
160            // Parse as days (e.g., "10d", "in 10d", "in 10 days")
161            Ok(Self::InDays(days))
162        } else {
163            Err(format!("Invalid timedelta format: {t}"))
164        }
165    }
166}
167
168/// Parse hours from string formats like "10h", "10 hours", "10hours", "in 10hours"
169fn 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
182/// Parse days from string formats like "10d", "in 10d", "in 10 days"
183fn 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; // DST
320        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        // Test parsing of DateTimeAnchor::Time variant
333        let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
334        let anchor = DateTimeAnchor::Time(time);
335
336        // Test with a sample date for parsing
337        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        // Both should have the same time (14:30) on the same date (2025-01-01)
342        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        // Test "HH:MM" (before now, should be tomorrow)
417        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        // Test "HH:MM" (after now, should be today)
427        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            // (current_hour, current_min, expected_hour, description)
512            (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        // After 6 PM, should suggest DateOnly (next day)
544        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        // Exactly at 6 PM, should suggest DateOnly (next day)
550        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}