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