aimcal_core/datetime/
anchor.rs

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