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};
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                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                // If the time has already passed today, use tomorrow
121                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        // Handle keywords
138        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            // Parse as datetime
148            Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)))
149        } else if let Ok(time) = NaiveTime::parse_from_str(t, "%H:%M") {
150            // Parse as time only
151            Ok(Self::Time(time))
152        } else if let Some(hours) = parse_hours(t) {
153            // Parse as hours (e.g., "10h", "10 hours", "10hours", "in 10hours")
154            Ok(Self::InHours(hours))
155        } else if let Some(days) = parse_days(t) {
156            // Parse as days (e.g., "10d", "in 10d", "in 10 days")
157            Ok(Self::InDays(days))
158        } else {
159            Err(format!("Invalid timedelta format: {t}"))
160        }
161    }
162}
163
164/// Parse hours from string formats like "10h", "10 hours", "10hours", "in 10hours"
165fn 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
178/// Parse days from string formats like "10d", "in 10d", "in 10 days"
179fn 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; // DST
303        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        // Test parsing of DateTimeAnchor::Time variant
316        let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
317        let anchor = DateTimeAnchor::Time(time);
318
319        // Test with a sample date for parsing
320        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        // Both should have the same time (14:30) on the same date (2025-01-01)
325        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        // Test "HH:MM" (before now, should be tomorrow)
400        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        // Test "HH:MM" (after now, should be today)
410        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}