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::{fmt, str::FromStr, sync::OnceLock};
6
7use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, TimeZone, Timelike};
8use regex::Regex;
9use serde::de;
10
11use crate::LooseDateTime;
12use crate::datetime::util::{end_of_day, from_local_datetime, start_of_day};
13
14/// Represents a date and time anchor that can be used to calculate relative dates and times.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DateTimeAnchor {
17    /// A specific number of days in the future or past.
18    InDays(i64),
19
20    /// A specific number of seconds in the future or past.
21    Relative(i64),
22
23    /// A specific date and time.
24    DateTime(LooseDateTime),
25
26    /// A specific time.
27    Time(NaiveTime),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum DayBoundary {
32    Start,
33    End,
34}
35
36impl DateTimeAnchor {
37    /// Represents the current time.
38    pub fn now() -> Self {
39        DateTimeAnchor::Relative(0)
40    }
41
42    /// Represents the current date.
43    pub fn today() -> Self {
44        DateTimeAnchor::InDays(0)
45    }
46
47    /// Represents tomorrow, which is one day after today.
48    pub fn tomorrow() -> Self {
49        DateTimeAnchor::InDays(1)
50    }
51
52    /// Represents yesterday, which is one day before today.
53    pub fn yesterday() -> Self {
54        DateTimeAnchor::InDays(-1)
55    }
56
57    /// Resolve datetime at the start of the day based on the provided current local time.
58    pub fn resolve_at_start_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
59        self.resolve_at_day_boundary(now, DayBoundary::Start)
60    }
61
62    /// Resolve datetime at the end of the day based on the provided current local time.
63    pub fn resolve_at_end_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
64        self.resolve_at_day_boundary(now, DayBoundary::End)
65    }
66
67    /// Resolve datetime based on the provided current local time and day boundary.
68    fn resolve_at_day_boundary<Tz: TimeZone>(
69        &self,
70        now: &DateTime<Tz>,
71        boundary: DayBoundary,
72    ) -> DateTime<Tz> {
73        match self {
74            DateTimeAnchor::InDays(n) => {
75                let dt = match boundary {
76                    DayBoundary::Start => start_of_day(now),
77                    DayBoundary::End => end_of_day(now),
78                };
79                dt + TimeDelta::days(*n)
80            }
81            DateTimeAnchor::Relative(n) => now.clone() + TimeDelta::seconds(*n),
82            DateTimeAnchor::DateTime(dt) => {
83                let naive = match boundary {
84                    DayBoundary::Start => dt.with_start_of_day(),
85                    DayBoundary::End => dt.with_end_of_day(),
86                };
87                from_local_datetime(&now.timezone(), naive)
88            }
89            DateTimeAnchor::Time(t) => {
90                let naive = NaiveDateTime::new(now.date_naive(), *t);
91                from_local_datetime(&now.timezone(), naive)
92            }
93        }
94    }
95
96    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` based on the provided current local time.
97    pub fn resolve_at(self, now: &LooseDateTime) -> LooseDateTime {
98        match self {
99            DateTimeAnchor::InDays(n) => *now + TimeDelta::days(n),
100            DateTimeAnchor::Relative(n) => *now + TimeDelta::seconds(n),
101            DateTimeAnchor::DateTime(dt) => dt,
102            DateTimeAnchor::Time(t) => match now {
103                LooseDateTime::Local(dt) => {
104                    let dt = NaiveDateTime::new(dt.date_naive(), t);
105                    from_local_datetime(&Local, dt).into()
106                }
107                LooseDateTime::Floating(dt) => {
108                    let dt = NaiveDateTime::new(dt.date(), t);
109                    LooseDateTime::Floating(dt)
110                }
111                LooseDateTime::DateOnly(d) => {
112                    let dt = NaiveDateTime::new(*d, t);
113                    LooseDateTime::from_local_datetime(dt)
114                }
115            },
116        }
117    }
118
119    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` starting from the provided `DateTime<Tz>`.
120    pub fn resolve_since(self, start: &LooseDateTime) -> LooseDateTime {
121        match self {
122            DateTimeAnchor::InDays(n) => match n {
123                0 => match start {
124                    LooseDateTime::Local(dt) => next_suggested_time(&dt.naive_local()),
125                    LooseDateTime::Floating(dt) => next_suggested_time(dt),
126                    LooseDateTime::DateOnly(d) => first_suggested_time(d),
127                },
128                _ => {
129                    let date = start.date() + TimeDelta::days(n);
130                    let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
131                    LooseDateTime::from_local_datetime(dt)
132                }
133            },
134            DateTimeAnchor::Relative(n) => *start + TimeDelta::seconds(n),
135            DateTimeAnchor::DateTime(dt) => dt,
136            DateTimeAnchor::Time(t) => {
137                let date = start.date();
138                // If the time has already passed today, use tomorrow
139                let delta = if let Some(s) = start.time()
140                    && s >= t
141                {
142                    TimeDelta::days(1)
143                } else {
144                    TimeDelta::zero()
145                };
146                let dt = NaiveDateTime::new(date, t) + delta;
147                LooseDateTime::from_local_datetime(dt)
148            }
149        }
150    }
151
152    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` starting from the provided `DateTime<Tz>`.
153    pub fn resolve_since_datetime<Tz: TimeZone>(self, start: &DateTime<Tz>) -> LooseDateTime {
154        match self {
155            DateTimeAnchor::InDays(n) => match n {
156                0 => next_suggested_time(&start.naive_local()),
157                _ => {
158                    let date = start.date_naive() + TimeDelta::days(n);
159                    let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
160                    LooseDateTime::from_local_datetime(dt)
161                }
162            },
163            DateTimeAnchor::Relative(n) => {
164                let dt = start.clone() + TimeDelta::seconds(n);
165                LooseDateTime::Local(dt.with_timezone(&Local))
166            }
167            DateTimeAnchor::DateTime(dt) => dt,
168            DateTimeAnchor::Time(t) => {
169                let date = start.date_naive();
170                // If the time has already passed today, use tomorrow
171                let delta = if start.time() >= t {
172                    TimeDelta::days(1)
173                } else {
174                    TimeDelta::zero()
175                };
176                let dt = NaiveDateTime::new(date, t) + delta;
177                LooseDateTime::from_local_datetime(dt)
178            }
179        }
180    }
181
182    /// Parses the `DateTimeAnchor` enum based on the current time.
183    // TODO: remove this function in 0.12.0
184    #[deprecated(
185        since = "0.9.0",
186        note = "use `resolve_at_start_of_day` method instead, will be removed in 0.12.0"
187    )]
188    pub fn parse_as_start_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
189        self.resolve_at_start_of_day(now)
190    }
191
192    /// Parses the `DateTimeAnchor` enum based on the current time.
193    // TODO: remove this function in 0.12.0
194    #[deprecated(
195        since = "0.9.0",
196        note = "use `resolve_at_end_of_day` method instead, will be removed in 0.12.0"
197    )]
198    pub fn parse_as_end_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
199        self.resolve_at_end_of_day(now)
200    }
201
202    /// Parses the `DateTimeAnchor` to a `LooseDateTime` based on the provided current local time.
203    #[deprecated(
204        since = "0.9.0",
205        note = "use `resolve_at` method instead, will be removed in 0.12.0"
206    )]
207    // TODO: remove this function in 0.12.0
208    pub fn parse_from_loose(self, now: &LooseDateTime) -> LooseDateTime {
209        self.resolve_at(now)
210    }
211
212    /// Parses the `DateTimeAnchor` to a `LooseDateTime` based on the provided current time in any timezone.
213    #[deprecated(
214        since = "0.9.0",
215        note = "use `resolve_since_datetime` method instead, will be removed in 0.12.0"
216    )]
217    // TODO: remove this function in 0.12.0
218    pub fn parse_from_dt<Tz: TimeZone>(self, now: &DateTime<Tz>) -> LooseDateTime {
219        self.resolve_since_datetime(now)
220    }
221}
222
223impl FromStr for DateTimeAnchor {
224    type Err = String;
225
226    fn from_str(t: &str) -> Result<Self, Self::Err> {
227        // Handle keywords
228        match t {
229            "yesterday" => return Ok(Self::yesterday()),
230            "tomorrow" => return Ok(Self::tomorrow()),
231            "today" => return Ok(Self::today()),
232            "now" => return Ok(Self::now()),
233            _ => {}
234        }
235
236        if let Ok(dt) = NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M") {
237            // Parse as datetime
238            Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)))
239        } else if let Ok(date) = NaiveDate::parse_from_str(t, "%Y-%m-%d") {
240            // Parse as date only
241            Ok(Self::DateTime(LooseDateTime::DateOnly(date)))
242        } else if let Ok(time) = NaiveTime::parse_from_str(t, "%H:%M") {
243            // Parse as time only
244            Ok(Self::Time(time))
245        } else if let Some(hours) = parse_seconds(t) {
246            // Parse as hours (e.g., "10s", "10sec", "10 seconds")
247            Ok(Self::Relative(hours))
248        } else if let Some(minutes) = parse_minutes(t) {
249            // Parse as hours (e.g., "10m", "10min", "10 minutes")
250            Ok(Self::Relative(60 * minutes))
251        } else if let Some(hours) = parse_hours(t) {
252            // Parse as hours (e.g., "10h", "10hours", "10hours")
253            Ok(Self::Relative(60 * 60 * hours))
254        } else if let Some(days) = parse_days(t) {
255            // Parse as days (e.g., "10d", "10days")
256            Ok(Self::InDays(days))
257        } else {
258            Err(format!("Invalid datetime anchor: {t}"))
259        }
260    }
261}
262
263impl<'de> serde::Deserialize<'de> for DateTimeAnchor {
264    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265    where
266        D: serde::Deserializer<'de>,
267    {
268        struct Visitor;
269
270        impl<'de> de::Visitor<'de> for Visitor {
271            type Value = DateTimeAnchor;
272
273            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
274                formatter.write_str("a string representing a datetime")
275            }
276
277            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
278            where
279                E: de::Error,
280            {
281                value.parse().map_err(de::Error::custom)
282            }
283        }
284
285        deserializer.deserialize_str(Visitor)
286    }
287}
288
289macro_rules! parse_with_regex {
290    ($fn: ident, $re:expr) => {
291        fn $fn(s: &str) -> Option<i64> {
292            static REGEX: OnceLock<Regex> = OnceLock::new();
293            let re = REGEX.get_or_init(|| Regex::new($re).unwrap());
294            if let Some(captures) = re.captures(s)
295                && let Ok(num) = captures[1].parse::<i64>()
296            {
297                return Some(num);
298            }
299            None
300        }
301    };
302}
303
304parse_with_regex!(parse_seconds, r"^\s*(\d+)\s*s(?:ec|econds)?\s*$"); // "10s", "10 sec", "10 seconds"
305parse_with_regex!(parse_minutes, r"^\s*(\d+)\s*m(?:in|inutes)?\s*$"); // "10m", "10 min", "10minutes"
306
307// TODO: remove "in xxx" support?
308parse_with_regex!(parse_hours, r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$"); // "10h", "10 hours", "10hours", "in 10hours"
309parse_with_regex!(parse_days, r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$"); // "10d", "in 10d", "in 10 days"
310
311const HOURS: [u32; 3] = [9, 13, 18];
312
313fn next_suggested_time(now: &NaiveDateTime) -> LooseDateTime {
314    let date = now.date();
315    let current_hour = now.hour();
316    for hour in HOURS {
317        if current_hour < hour {
318            let dt = NaiveDateTime::new(date, NaiveTime::from_hms_opt(hour, 0, 0).unwrap());
319            return LooseDateTime::from_local_datetime(dt);
320        }
321    }
322
323    LooseDateTime::DateOnly(date)
324}
325
326fn first_suggested_time(date: &NaiveDate) -> LooseDateTime {
327    let dt = NaiveDateTime::new(*date, NaiveTime::from_hms_opt(HOURS[0], 0, 0).unwrap());
328    LooseDateTime::from_local_datetime(dt)
329}
330
331#[cfg(test)]
332mod tests {
333    use chrono::{NaiveDate, Utc};
334
335    use super::*;
336
337    #[test]
338    fn test_anchor_now() {
339        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
340        assert_eq!(DateTimeAnchor::now().resolve_at_start_of_day(&now), now);
341        assert_eq!(DateTimeAnchor::now().resolve_at_end_of_day(&now), now);
342        #[allow(deprecated)]
343        {
344            assert_eq!(DateTimeAnchor::now().parse_as_start_of_day(&now), now);
345            assert_eq!(DateTimeAnchor::now().parse_as_end_of_day(&now), now);
346        }
347    }
348
349    #[test]
350    fn test_anchor_in_days() {
351        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
352        let anchor = DateTimeAnchor::InDays(1);
353
354        let expected = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
355
356        let parsed = anchor.resolve_at_start_of_day(&now);
357        assert_eq!(parsed, expected);
358
359        let parsed = anchor.resolve_at_end_of_day(&now);
360        assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
361        assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
362
363        // Test deprecated functions still work
364        #[allow(deprecated)]
365        {
366            let parsed = anchor.parse_as_start_of_day(&now);
367            assert_eq!(parsed, expected);
368
369            let parsed = anchor.parse_as_end_of_day(&now);
370            assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
371            assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
372        }
373    }
374
375    #[test]
376    fn test_anchor_relative() {
377        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap();
378        let anchor = DateTimeAnchor::Relative(2 * 60 * 60 + 30 * 60 + 45);
379
380        let parsed = anchor.resolve_at_start_of_day(&now);
381        let expected = Utc.with_ymd_and_hms(2025, 1, 1, 17, 30, 45).unwrap();
382        assert_eq!(parsed, expected);
383
384        let parsed = anchor.resolve_at_end_of_day(&now);
385        assert_eq!(parsed, expected);
386
387        // Test deprecated functions still work
388        #[allow(deprecated)]
389        {
390            let parsed = anchor.parse_as_start_of_day(&now);
391            assert_eq!(parsed, expected);
392
393            let parsed = anchor.parse_as_end_of_day(&now);
394            assert_eq!(parsed, expected);
395        }
396    }
397
398    #[test]
399    fn test_anchor_time_dateonly() {
400        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
401        let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
402        let loose_date = LooseDateTime::DateOnly(date);
403        let anchor = DateTimeAnchor::DateTime(loose_date);
404
405        let parsed = anchor.resolve_at_start_of_day(&now);
406        let expected = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
407        assert_eq!(parsed, expected);
408
409        let parsed = anchor.resolve_at_end_of_day(&now);
410        assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 5, 23, 59, 59).unwrap());
411        assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 6, 0, 0, 0).unwrap());
412
413        // Test deprecated functions still work
414        #[allow(deprecated)]
415        {
416            let parsed = anchor.parse_as_start_of_day(&now);
417            assert_eq!(parsed, expected);
418
419            let parsed = anchor.parse_as_end_of_day(&now);
420            assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 5, 23, 59, 59).unwrap());
421            assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 6, 0, 0, 0).unwrap());
422        }
423    }
424
425    #[test]
426    fn test_anchor_time_floating() {
427        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
428        let date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
429        let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
430        let datetime = NaiveDateTime::new(date, time);
431        let loose_datetime = LooseDateTime::Floating(datetime);
432        let anchor = DateTimeAnchor::DateTime(loose_datetime);
433
434        let parsed = anchor.resolve_at_start_of_day(&now);
435        let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
436        assert_eq!(parsed, expected);
437
438        let parsed = anchor.resolve_at_end_of_day(&now);
439        let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
440        assert_eq!(parsed, expected);
441
442        // Test deprecated functions still work
443        #[allow(deprecated)]
444        {
445            let parsed = anchor.parse_as_start_of_day(&now);
446            assert_eq!(parsed, expected);
447
448            let parsed = anchor.parse_as_end_of_day(&now);
449            assert_eq!(parsed, expected);
450        }
451    }
452
453    #[test]
454    fn test_anchor_time_local() {
455        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
456        let local_dt = Local.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
457        let loose_local = LooseDateTime::Local(local_dt);
458        let anchor = DateTimeAnchor::DateTime(loose_local);
459
460        let parsed = anchor.resolve_at_start_of_day(&now);
461        let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
462        assert_eq!(parsed, expected);
463
464        let parsed = anchor.resolve_at_end_of_day(&now);
465        let expected = Utc.with_ymd_and_hms(2025, 1, 5, 14, 30, 0).unwrap();
466        assert_eq!(parsed, expected);
467
468        // Test deprecated functions still work
469        #[allow(deprecated)]
470        {
471            let parsed = anchor.parse_as_start_of_day(&now);
472            assert_eq!(parsed, expected);
473
474            let parsed = anchor.parse_as_end_of_day(&now);
475            assert_eq!(parsed, expected);
476        }
477    }
478
479    #[test]
480    fn test_start_of_day() {
481        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 59).unwrap();
482        let parsed = start_of_day(&now);
483        let expected = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
484        assert_eq!(parsed, expected);
485    }
486
487    #[test]
488    fn test_end_of_day() {
489        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
490        let parsed = end_of_day(&now);
491        let last_sec = Utc.with_ymd_and_hms(2025, 1, 1, 23, 59, 59).unwrap();
492        let next_day = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
493        assert!(parsed > last_sec);
494        assert!(parsed < next_day);
495    }
496
497    #[test]
498    fn test_from_local_datetime_dst_ambiguity_pick_earliest() {
499        let tz = chrono_tz::America::New_York; // DST
500        let now = NaiveDateTime::new(
501            NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
502            NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
503        );
504
505        let parsed = from_local_datetime(&tz, now).with_timezone(&Utc);
506        let expected = Utc.with_ymd_and_hms(2025, 11, 2, 5, 30, 0).unwrap();
507        assert_eq!(parsed, expected);
508    }
509
510    #[test]
511    fn test_time_parsing() {
512        // Test parsing of DateTimeAnchor::Time variant
513        let time = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
514        let anchor = DateTimeAnchor::Time(time);
515
516        // Test with a sample date for parsing
517        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
518        let parsed_start = anchor.resolve_at_start_of_day(&now);
519        let parsed_end = anchor.resolve_at_end_of_day(&now);
520
521        // Both should have the same time (14:30) on the same date (2025-01-01)
522        assert_eq!(parsed_start.date_naive(), now.date_naive());
523        assert_eq!(parsed_start.time(), time);
524        assert_eq!(parsed_end.date_naive(), now.date_naive());
525        assert_eq!(parsed_end.time(), time);
526
527        // Test deprecated functions still work
528        #[allow(deprecated)]
529        {
530            let parsed_start = anchor.parse_as_start_of_day(&now);
531            let parsed_end = anchor.parse_as_end_of_day(&now);
532
533            // Both should have the same time (14:30) on the same date (2025-01-01)
534            assert_eq!(parsed_start.date_naive(), now.date_naive());
535            assert_eq!(parsed_start.time(), time);
536            assert_eq!(parsed_end.date_naive(), now.date_naive());
537            assert_eq!(parsed_end.time(), time);
538        }
539    }
540
541    #[test]
542    fn test_resolve_at_in_days() {
543        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap());
544        let anchor = DateTimeAnchor::DateTime(expected);
545        let result = anchor.resolve_at(&expected);
546        assert_eq!(result, expected);
547
548        // Test deprecated function still works
549        #[allow(deprecated)]
550        {
551            let result = anchor.parse_from_loose(&expected);
552            assert_eq!(result, expected);
553        }
554    }
555
556    #[test]
557    fn test_resolve_at_relative() {
558        let anchor = DateTimeAnchor::Relative(3 * 60 * 60);
559        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
560        let result = anchor.resolve_at(&now.into());
561        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap());
562        assert_eq!(result, expected);
563
564        // Test deprecated function still works
565        #[allow(deprecated)]
566        {
567            let result = anchor.parse_from_loose(&now.into());
568            assert_eq!(result, expected);
569        }
570    }
571
572    #[test]
573    fn test_resolve_at_datetime() {
574        let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
575            Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
576        ));
577        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
578        let result = anchor.resolve_at(&now.into());
579        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
580        assert_eq!(result, expected);
581
582        // Test deprecated function still works
583        #[allow(deprecated)]
584        {
585            let result = anchor.parse_from_loose(&now.into());
586            assert_eq!(result, expected);
587        }
588    }
589
590    #[test]
591    fn test_resolve_at_time() {
592        let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
593        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
594        let result = anchor.resolve_at(&now.into());
595        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
596        assert_eq!(result, expected);
597
598        // Test deprecated function still works
599        #[allow(deprecated)]
600        {
601            let result = anchor.parse_from_loose(&now.into());
602            assert_eq!(result, expected);
603        }
604    }
605
606    #[test]
607    fn test_resolve_since_in_days() {
608        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap().into();
609        let expected = now;
610        let anchor = DateTimeAnchor::DateTime(expected);
611        let result = anchor.resolve_since(&now);
612        assert_eq!(result, expected);
613    }
614
615    #[test]
616    fn test_resolve_since_relative() {
617        let anchor = DateTimeAnchor::Relative(3 * 60 * 60 + 25 * 60 + 45);
618        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap().into();
619        let result = anchor.resolve_since(&now);
620        let expected =
621            LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 25, 45).unwrap());
622        assert_eq!(result, expected);
623    }
624
625    #[test]
626    fn test_resolve_since_datetime() {
627        let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
628            Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
629        ));
630        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap().into();
631        let result = anchor.resolve_since(&now);
632        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
633        assert_eq!(result, expected);
634    }
635
636    #[test]
637    fn test_resolve_since_time_before_now() {
638        // Test "HH:MM" (before now, should be tomorrow)
639        let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
640        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap().into();
641        let result = anchor.resolve_since(&now);
642        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 2, 10, 0, 0).unwrap());
643        assert_eq!(result, expected);
644    }
645
646    #[test]
647    fn test_resolve_since_time_after_now() {
648        // Test "HH:MM" (after now, should be today)
649        let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 0, 0).unwrap());
650        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap().into();
651        let result = anchor.resolve_since(&now);
652        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap());
653        assert_eq!(result, expected);
654    }
655
656    #[test]
657    fn test_resolve_since_datetime_in_days() {
658        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
659        let expected = now.into();
660        let anchor = DateTimeAnchor::DateTime(expected);
661        let result = anchor.resolve_since_datetime(&now);
662        assert_eq!(result, expected);
663
664        // Test deprecated function still works
665        #[allow(deprecated)]
666        {
667            let result = anchor.parse_from_dt(&now);
668            assert_eq!(result, expected);
669        }
670    }
671
672    #[test]
673    fn test_resolve_since_datetime_relative() {
674        let anchor = DateTimeAnchor::Relative(3 * 60 * 60 + 25 * 60 + 45);
675        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
676        let result = anchor.resolve_since_datetime(&now);
677        let expected =
678            LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 15, 25, 45).unwrap());
679        assert_eq!(result, expected);
680
681        // Test deprecated function still works
682        #[allow(deprecated)]
683        {
684            let result = anchor.parse_from_dt(&now);
685            assert_eq!(result, expected);
686        }
687    }
688
689    #[test]
690    fn test_resolve_since_datetime_datetime() {
691        let anchor = DateTimeAnchor::DateTime(LooseDateTime::Local(
692            Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(),
693        ));
694        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
695        let result = anchor.resolve_since_datetime(&now);
696        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap());
697        assert_eq!(result, expected);
698
699        // Test deprecated function still works
700        #[allow(deprecated)]
701        {
702            let result = anchor.parse_from_dt(&now);
703            assert_eq!(result, expected);
704        }
705    }
706
707    #[test]
708    fn test_resolve_since_datetime_time_before_now() {
709        // Test "HH:MM" (before now, should be tomorrow)
710        let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(10, 0, 0).unwrap());
711        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
712        let result = anchor.resolve_since_datetime(&now);
713        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 2, 10, 0, 0).unwrap());
714        assert_eq!(result, expected);
715
716        // Test deprecated function still works
717        #[allow(deprecated)]
718        {
719            let result = anchor.parse_from_dt(&now);
720            assert_eq!(result, expected);
721        }
722    }
723
724    #[test]
725    fn test_resolve_since_datetime_time_after_now() {
726        // Test "HH:MM" (after now, should be today)
727        let anchor = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 0, 0).unwrap());
728        let now = Local.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
729        let result = anchor.resolve_since_datetime(&now);
730        let expected = LooseDateTime::Local(Local.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap());
731        assert_eq!(result, expected);
732
733        // Test deprecated function still works
734        #[allow(deprecated)]
735        {
736            let result = anchor.parse_from_dt(&now);
737            assert_eq!(result, expected);
738        }
739    }
740
741    #[test]
742    fn test_from_str_keywords() {
743        for (s, expected) in [
744            ("now", DateTimeAnchor::now()),
745            ("today", DateTimeAnchor::today()),
746            ("yesterday", DateTimeAnchor::yesterday()),
747            ("tomorrow", DateTimeAnchor::tomorrow()),
748        ] {
749            let anchor: DateTimeAnchor = s.parse().unwrap();
750            assert_eq!(anchor, expected);
751        }
752    }
753
754    #[test]
755    fn test_from_str_datetime() {
756        let anchor: DateTimeAnchor = "2025-01-15 14:30".parse().unwrap();
757        let expected = DateTimeAnchor::DateTime(LooseDateTime::Local(
758            Local.with_ymd_and_hms(2025, 1, 15, 14, 30, 0).unwrap(),
759        ));
760        assert_eq!(anchor, expected);
761    }
762
763    #[test]
764    fn test_from_str_time() {
765        let anchor: DateTimeAnchor = "14:30".parse().unwrap();
766        let expected = DateTimeAnchor::Time(NaiveTime::from_hms_opt(14, 30, 0).unwrap());
767        assert_eq!(anchor, expected);
768    }
769
770    #[test]
771    fn test_from_str_invalid() {
772        let result = DateTimeAnchor::from_str("invalid");
773        assert!(result.is_err());
774        assert!(result.unwrap_err().contains("Invalid datetime anchor"));
775    }
776
777    #[test]
778    fn test_from_str_seconds() {
779        for s in [
780            "10s",
781            "10sec",
782            "10seconds",
783            "   10   s   ",
784            "   10   sec   ",
785            "   10   seconds   ",
786        ] {
787            let anchor: DateTimeAnchor = s.parse().unwrap();
788            let expected = DateTimeAnchor::Relative(10);
789            assert_eq!(anchor, expected, "Failed to parse '{}'", s);
790
791            // No "in " prefix allowed for seconds
792            let prefix_in = DateTimeAnchor::from_str(&format!("in {s}"));
793            assert!(prefix_in.is_err());
794
795            // No uppercase allowed for seconds
796            let uppercase = DateTimeAnchor::from_str(&s.to_uppercase());
797            assert!(uppercase.is_err());
798        }
799    }
800
801    #[test]
802    fn test_from_str_minutes() {
803        for s in [
804            "10m",
805            "10min",
806            "10minutes",
807            "   10   m   ",
808            "   10   min   ",
809            "   10   minutes   ",
810        ] {
811            let anchor: DateTimeAnchor = s.parse().unwrap();
812            let expected = DateTimeAnchor::Relative(10 * 60);
813            assert_eq!(anchor, expected, "Failed to parse '{}'", s);
814
815            // No "in " prefix allowed for minutes
816            let prefix_in = DateTimeAnchor::from_str(&format!("in {s}"));
817            assert!(prefix_in.is_err());
818
819            // No uppercase allowed for minutes
820            let uppercase = DateTimeAnchor::from_str(&s.to_uppercase());
821            assert!(uppercase.is_err());
822        }
823    }
824
825    #[test]
826    fn test_from_str_hours() {
827        for s in [
828            "in 10hours",
829            "in 10H",
830            "   IN   10   hours   ",
831            "10hours",
832            "10 HOURS",
833            "   10   hours   ",
834            "10h",
835            "10 H",
836            "   10   h   ",
837        ] {
838            let anchor: DateTimeAnchor = s.parse().unwrap();
839            let expected = DateTimeAnchor::Relative(10 * 60 * 60);
840            assert_eq!(anchor, expected, "Failed to parse '{}'", s);
841        }
842    }
843
844    #[test]
845    fn test_from_str_days() {
846        for s in [
847            "in 10days",
848            "in 10D",
849            "   IN   10   days   ",
850            "10days",
851            "10 DAYS",
852            "   10   days   ",
853            "10d",
854            "10 D",
855            "   10   d   ",
856        ] {
857            let anchor: DateTimeAnchor = s.parse().unwrap();
858            let expected = DateTimeAnchor::InDays(10);
859            assert_eq!(anchor, expected, "Failed to parse '{}'", s);
860        }
861    }
862
863    #[test]
864    fn test_next_suggested_time() {
865        let test_cases = vec![
866            // (current_hour, current_min, expected_hour, description)
867            (8, 30, 9, "Before 9 AM, should suggest 9 AM"),
868            (
869                10,
870                30,
871                13,
872                "After 9 AM but before 1 PM, should suggest 1 PM",
873            ),
874            (
875                14,
876                30,
877                18,
878                "After 1 PM but before 6 PM, should suggest 6 PM",
879            ),
880            (9, 0, 13, "Exactly at 9 AM, should suggest 1 PM"),
881            (13, 0, 18, "Exactly at 1 PM, should suggest 6 PM"),
882        ];
883
884        for (hour, min, expected_hour, description) in test_cases {
885            let now = Local.with_ymd_and_hms(2025, 1, 1, hour, min, 0).unwrap();
886            let result = next_suggested_time(&now.naive_local());
887            let expected = LooseDateTime::Local(
888                Local
889                    .with_ymd_and_hms(2025, 1, 1, expected_hour, 0, 0)
890                    .unwrap(),
891            );
892            assert_eq!(result, expected, "{}", description);
893        }
894    }
895
896    #[test]
897    fn test_next_suggested_time_after_6pm() {
898        // After 6 PM, should suggest DateOnly (next day)
899        let now = Local.with_ymd_and_hms(2025, 1, 1, 19, 30, 0).unwrap();
900        let result = next_suggested_time(&now.naive_local());
901        let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
902        assert_eq!(result, expected, "After 6 PM, should suggest DateOnly");
903
904        // Exactly at 6 PM, should suggest DateOnly (next day)
905        let now = Local.with_ymd_and_hms(2025, 1, 1, 18, 0, 0).unwrap();
906        let result = next_suggested_time(&now.naive_local());
907        let expected = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
908        assert_eq!(result, expected, "Exactly at 6 PM, should suggest DateOnly");
909    }
910}