Skip to main content

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::{Date, DateTime, Time, Weekday, time};
8use jiff::{Span, Zoned};
9use regex::Regex;
10use serde::de;
11use tracing::warn;
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)]
17#[non_exhaustive]
18pub enum DateTimeAnchor {
19    /// A specific number of days in the future or past.
20    InDays(i64),
21    /// A specific number of seconds in the future or past.
22    Relative(i64),
23    /// A specific date and time.
24    DateTime(LooseDateTime),
25    /// A specific time.
26    Time(Time),
27    /// A month and day without year (year resolved at resolution time).
28    MonthDay(i8, i8),
29    /// A weekday with optional offset in weeks.
30    Weekday {
31        /// The target weekday.
32        day: Weekday,
33        /// Offset for weekday resolution in weeks.
34        ///
35        /// - `0` = This/next occurrence (could be today if same weekday)
36        /// - `1` = Next week's occurrence
37        /// - `2` = 2 weeks from now
38        /// - `-1` = Last/previous occurrence
39        /// - `-2` = 2 weeks ago
40        offset: i8,
41    },
42}
43
44impl DateTimeAnchor {
45    /// Represents the current time.
46    #[must_use]
47    pub fn now() -> Self {
48        DateTimeAnchor::Relative(0)
49    }
50
51    /// Represents the current date.
52    #[must_use]
53    pub fn today() -> Self {
54        DateTimeAnchor::InDays(0)
55    }
56
57    /// Represents tomorrow, which is one day after today.
58    #[must_use]
59    pub fn tomorrow() -> Self {
60        DateTimeAnchor::InDays(1)
61    }
62
63    /// Represents yesterday, which is one day before today.
64    #[must_use]
65    pub fn yesterday() -> Self {
66        DateTimeAnchor::InDays(-1)
67    }
68
69    /// Resolve datetime at the start of the day based on the provided current local time.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if date/time operations fail.
74    pub fn resolve_at_start_of_day(&self, now: &Zoned) -> Result<Zoned, String> {
75        match self {
76            DateTimeAnchor::InDays(n) => now
77                .start_of_day()
78                .map_err(|e| format!("Failed to get start of day: {e}"))?
79                .checked_add(Span::new().days(*n))
80                .map_err(|e| format!("Failed to add days to start of day: {e}")),
81            DateTimeAnchor::Relative(n) => now
82                .checked_add(Span::new().seconds(*n))
83                .map_err(|e| format!("Failed to add relative seconds: {e}")),
84            DateTimeAnchor::DateTime(dt) => dt
85                .with_start_of_day()
86                .to_zoned(now.time_zone().clone())
87                .map_err(|e| format!("Failed to convert to zoned: {e}")),
88            DateTimeAnchor::Time(t) => now
89                .with()
90                .time(*t)
91                .build()
92                .map_err(|e| format!("Failed to build zoned: {e}")),
93            DateTimeAnchor::MonthDay(month, day) => Date::new(now.date().year(), *month, *day)
94                .map_err(|e| format!("Failed to create date: {e}"))?
95                .to_zoned(now.time_zone().clone())
96                .map_err(|e| format!("Failed to convert to zoned: {e}")),
97            DateTimeAnchor::Weekday { day, offset } => {
98                let date = resolve_weekday_date(now.date(), *day, *offset);
99                date.to_zoned(now.time_zone().clone())
100                    .map_err(|e| format!("Failed to convert to zoned: {e}"))
101            }
102        }
103    }
104
105    /// Resolve datetime at the end of the day based on the provided current local time.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if date/time operations fail.
110    pub fn resolve_at_end_of_day(&self, now: &Zoned) -> Result<Zoned, String> {
111        match self {
112            DateTimeAnchor::InDays(n) => now
113                .end_of_day()
114                .map_err(|e| format!("Failed to get end of day: {e}"))?
115                .checked_add(Span::new().days(*n))
116                .map_err(|e| format!("Failed to add days to end of day: {e}")),
117            DateTimeAnchor::Relative(n) => now
118                .checked_add(Span::new().seconds(*n))
119                .map_err(|e| format!("Failed to add relative seconds: {e}")),
120            DateTimeAnchor::DateTime(dt) => dt
121                .with_end_of_day()
122                .to_zoned(now.time_zone().clone())
123                .map_err(|e| format!("Failed to convert to zoned: {e}")),
124            DateTimeAnchor::Time(t) => now
125                .with()
126                .time(*t)
127                .build()
128                .map_err(|e| format!("Failed to build zoned: {e}")),
129            DateTimeAnchor::MonthDay(month, day) => Date::new(now.date().year(), *month, *day)
130                .map_err(|e| format!("Failed to create date: {e}"))?
131                .to_zoned(now.time_zone().clone())
132                .map_err(|e| format!("Failed to convert to zoned: {e}"))?
133                .end_of_day()
134                .map_err(|e| format!("Failed to get end of day: {e}")),
135            DateTimeAnchor::Weekday { day, offset } => {
136                resolve_weekday_date(now.date(), *day, *offset)
137                    .to_zoned(now.time_zone().clone())
138                    .map_err(|e| format!("Failed to convert to zoned: {e}"))?
139                    .end_of_day()
140                    .map_err(|e| format!("Failed to get end of day: {e}"))
141            }
142        }
143    }
144
145    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` based on the provided current local time.
146    #[must_use]
147    pub fn resolve_at(self, now: &LooseDateTime) -> LooseDateTime {
148        match self {
149            DateTimeAnchor::InDays(n) => now.clone() + Span::new().days(n),
150            DateTimeAnchor::Relative(n) => now.clone() + Span::new().seconds(n),
151            DateTimeAnchor::DateTime(dt) => dt,
152            DateTimeAnchor::Time(t) => {
153                let dt = DateTime::from_parts(now.date(), t);
154                LooseDateTime::from_local_datetime(dt)
155            }
156            DateTimeAnchor::MonthDay(month, day) => {
157                match Date::new(now.date().year(), month, day) {
158                    Ok(d) => LooseDateTime::DateOnly(d),
159                    Err(_) => now.clone(), // Fallback to now if invalid date
160                }
161            }
162            DateTimeAnchor::Weekday { day, offset } => {
163                let date = resolve_weekday_date(now.date(), day, offset);
164                LooseDateTime::DateOnly(date)
165            }
166        }
167    }
168
169    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` starting from the provided `LooseDateTime`.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if date/time operations fail.
174    pub fn resolve_since(self, start: &LooseDateTime) -> Result<LooseDateTime, String> {
175        match self {
176            DateTimeAnchor::InDays(n) => {
177                if n == 0 {
178                    Ok(match start {
179                        LooseDateTime::Local(zoned) => next_suggested_time(&zoned.datetime()),
180                        LooseDateTime::Floating(dt) => next_suggested_time(dt),
181                        LooseDateTime::DateOnly(d) => first_suggested_time(*d),
182                    })
183                } else {
184                    let date = start
185                        .date()
186                        .checked_add(Span::new().days(n))
187                        .map_err(|e| format!("Failed to add days to start date: {e}"))?;
188                    let t = time(9, 0, 0, 0);
189                    let dt = DateTime::from_parts(date, t);
190                    Ok(LooseDateTime::from_local_datetime(dt))
191                }
192            }
193            DateTimeAnchor::Relative(n) => Ok(start.clone() + Span::new().seconds(n)),
194            DateTimeAnchor::DateTime(dt) => Ok(dt),
195            DateTimeAnchor::Time(t) => {
196                let mut date = start.date();
197                // If the time has already passed today, use tomorrow
198                if start.time().is_some_and(|s| s >= t) {
199                    date = date
200                        .checked_add(Span::new().days(1))
201                        .map_err(|e| format!("Failed to add day to date: {e}"))?;
202                }
203                let dt = DateTime::from_parts(date, t);
204                Ok(LooseDateTime::from_local_datetime(dt))
205            }
206            DateTimeAnchor::MonthDay(month, day) => {
207                let year = start.date().year();
208                let d = Date::new(year, month, day)
209                    .map_err(|e| format!("Failed to create date: {e}"))?;
210                Ok(LooseDateTime::DateOnly(d))
211            }
212            DateTimeAnchor::Weekday { day, offset } => {
213                let date = resolve_weekday_date(start.date(), day, offset);
214                Ok(LooseDateTime::DateOnly(date))
215            }
216        }
217    }
218
219    /// Resolve the `DateTimeAnchor` to a `LooseDateTime` starting from the provided `Zoned`.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if date/time operations fail.
224    pub fn resolve_since_zoned(self, start: &Zoned) -> Result<LooseDateTime, String> {
225        match self {
226            DateTimeAnchor::InDays(n) => {
227                if n == 0 {
228                    Ok(next_suggested_time(&start.datetime()))
229                } else {
230                    let date = start
231                        .datetime()
232                        .date()
233                        .checked_add(Span::new().days(n))
234                        .map_err(|e| format!("Failed to add days to start date: {e}"))?;
235                    let t = time(9, 0, 0, 0);
236                    let dt = DateTime::from_parts(date, t);
237                    Ok(LooseDateTime::from_local_datetime(dt))
238                }
239            }
240            DateTimeAnchor::Relative(n) => {
241                let zoned = start
242                    .checked_add(Span::new().seconds(n))
243                    .map_err(|e| format!("Failed to add relative seconds: {e}"))?;
244                Ok(LooseDateTime::Local(zoned))
245            }
246            DateTimeAnchor::DateTime(dt) => Ok(dt),
247            DateTimeAnchor::Time(t) => {
248                let mut date = start.date();
249                // If the time has already passed today, use tomorrow
250                if start.time() >= t {
251                    date = date
252                        .checked_add(Span::new().days(1))
253                        .map_err(|e| format!("Failed to add day to date: {e}"))?;
254                }
255                let dt = DateTime::from_parts(date, t);
256                Ok(LooseDateTime::from_local_datetime(dt))
257            }
258            DateTimeAnchor::MonthDay(month, day) => {
259                let year = start.date().year();
260                let d = Date::new(year, month, day)
261                    .map_err(|e| format!("Failed to create date: {e}"))?;
262                Ok(LooseDateTime::DateOnly(d))
263            }
264            DateTimeAnchor::Weekday { day, offset } => {
265                let date = resolve_weekday_date(start.date(), day, offset);
266                Ok(LooseDateTime::DateOnly(date))
267            }
268        }
269    }
270}
271
272impl FromStr for DateTimeAnchor {
273    type Err = String;
274
275    fn from_str(t: &str) -> Result<Self, Self::Err> {
276        // Handle keywords
277        match t {
278            "yesterday" => return Ok(Self::yesterday()),
279            "tomorrow" => return Ok(Self::tomorrow()),
280            "today" => return Ok(Self::today()),
281            "now" => return Ok(Self::now()),
282            _ => {}
283        }
284
285        // Try datetime
286        if let Ok(dt) = DateTime::strptime("%Y-%m-%d %H:%M", t) {
287            return Ok(Self::DateTime(LooseDateTime::from_local_datetime(dt)));
288        }
289
290        // Try date
291        if let Ok(d) = Date::strptime("%Y-%m-%d", t) {
292            return Ok(Self::DateTime(LooseDateTime::DateOnly(d)));
293        }
294
295        // Try month-day format (year resolved at resolution time)
296        if let Some((month, day)) = parse_month_day(t) {
297            return Ok(Self::MonthDay(month, day));
298        }
299
300        // Try time
301        if let Ok(time) = Time::strptime("%H:%M", t) {
302            return Ok(Self::Time(time));
303        }
304
305        // Try durations
306        if let Some(seconds) = parse_seconds(t) {
307            return Ok(Self::Relative(seconds));
308        }
309        if let Some(minutes) = parse_minutes(t) {
310            return Ok(Self::Relative(minutes * 60));
311        }
312        if let Some(hours) = parse_hours(t) {
313            return Ok(Self::Relative(hours * 60 * 60));
314        }
315        if let Some(days) = parse_days(t) {
316            return Ok(Self::InDays(days));
317        }
318
319        // Try weekday expressions (e.g., "monday", "next friday", "last tuesday")
320        if let Some((day, offset)) = parse_weekday_expression(t) {
321            return Ok(Self::Weekday { day, offset });
322        }
323
324        Err(format!("Invalid datetime anchor: {t}"))
325    }
326}
327
328impl<'de> serde::Deserialize<'de> for DateTimeAnchor {
329    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
330    where
331        D: serde::Deserializer<'de>,
332    {
333        struct Visitor;
334
335        impl de::Visitor<'_> for Visitor {
336            type Value = DateTimeAnchor;
337
338            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
339                formatter.write_str("a string representing a datetime")
340            }
341
342            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
343            where
344                E: de::Error,
345            {
346                value.parse().map_err(de::Error::custom)
347            }
348        }
349
350        deserializer.deserialize_str(Visitor)
351    }
352}
353
354macro_rules! parse_with_regex {
355    ($fn:ident, $re:expr) => {
356        fn $fn(s: &str) -> Option<i64> {
357            static REGEX: OnceLock<Regex> = OnceLock::new();
358            let re = REGEX.get_or_init(|| Regex::new($re).unwrap());
359            if let Some(captures) = re.captures(s)
360                && let Ok(num) = captures[1].parse::<i64>()
361            {
362                return Some(num);
363            }
364            None
365        }
366    };
367}
368
369// Parse duration with optional deprecated "in xxx" prefix support.
370// TODO(v0.14.0): remove this macro and use `parse_with_regex` instead
371macro_rules! parse_with_regex_and_deprecated_in {
372    ($fn:ident, $re:expr, $unit:literal) => {
373        fn $fn(s: &str) -> Option<i64> {
374            static REGEX: OnceLock<Regex> = OnceLock::new();
375            let re = REGEX.get_or_init(|| Regex::new($re).unwrap());
376            if let Some(captures) = re.captures(s)
377                && let Ok(num) = captures[1].parse::<i64>()
378            {
379                if s.to_lowercase().contains("in ") {
380                    warn!(
381                        "\"in xxx\" duration syntax is deprecated and will be removed in v0.14.0. \
382                         Use \"{num}{}\" instead of \"{}\"",
383                        $unit,
384                        s.trim()
385                    );
386                }
387                return Some(num);
388            }
389            None
390        }
391    };
392}
393
394parse_with_regex!(parse_seconds, r"^\s*(\d+)\s*s(?:ec|econds)?\s*$"); // "10s", "10 sec", "10 seconds"
395parse_with_regex!(parse_minutes, r"^\s*(\d+)\s*m(?:in|inutes)?\s*$"); // "10m", "10 min", "10minutes"
396
397parse_with_regex_and_deprecated_in!(
398    parse_hours,
399    r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$",
400    "h"
401); // "10h", "10 hours", "in 10h", "in 10 hours"
402parse_with_regex_and_deprecated_in!(parse_days, r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$", "d"); // "10d", "10 days", "in 10d", "in 10 days"
403
404const HOURS: [i8; 3] = [9, 13, 18];
405
406fn next_suggested_time(now: &DateTime) -> LooseDateTime {
407    let date = now.date();
408    let current_hour = now.hour();
409    for &hour in &HOURS {
410        if current_hour < hour {
411            let time = time(hour, 0, 0, 0);
412            let dt = DateTime::from_parts(date, time);
413            return LooseDateTime::from_local_datetime(dt);
414        }
415    }
416
417    LooseDateTime::DateOnly(date)
418}
419
420fn first_suggested_time(date: Date) -> LooseDateTime {
421    let time = time(HOURS[0], 0, 0, 0);
422    let dt = DateTime::from_parts(date, time);
423    LooseDateTime::from_local_datetime(dt)
424}
425
426fn parse_month_day(s: &str) -> Option<(i8, i8)> {
427    static REGEX: OnceLock<Regex> = OnceLock::new();
428    let re = REGEX.get_or_init(|| Regex::new(r"^(\d{1,2})-(\d{1,2})$").unwrap());
429    if let Some(captures) = re.captures(s)
430        && let Ok(month) = captures[1].parse::<i8>()
431        && let Ok(day) = captures[2].parse::<i8>()
432        && is_valid_month_day(month, day)
433    {
434        Some((month, day))
435    } else {
436        None
437    }
438}
439
440/// Checks if the month-day combination is valid.
441fn is_valid_month_day(month: i8, day: i8) -> bool {
442    // Month must be 1-12, day must be at least 1
443    if !(1..=12).contains(&month) || day < 1 {
444        return false;
445    }
446
447    // Maximum days per month
448    let max_day = match month {
449        2 => 29,              // February (allow 29 for leap years, validated at resolution)
450        4 | 6 | 9 | 11 => 30, // 30-day months
451        _ => 31,              // 31-day months (1, 3, 5, 7, 8, 10, 12)
452    };
453
454    day <= max_day
455}
456
457/// Parse weekday name (full or abbreviated, case-insensitive) to `Weekday`.
458fn parse_weekday_name(s: &str) -> Option<Weekday> {
459    let lower = s.to_lowercase();
460    match lower.as_str() {
461        "monday" | "mon" => Some(Weekday::Monday),
462        "tuesday" | "tue" => Some(Weekday::Tuesday),
463        "wednesday" | "wed" => Some(Weekday::Wednesday),
464        "thursday" | "thu" => Some(Weekday::Thursday),
465        "friday" | "fri" => Some(Weekday::Friday),
466        "saturday" | "sat" => Some(Weekday::Saturday),
467        "sunday" | "sun" => Some(Weekday::Sunday),
468        _ => None,
469    }
470}
471
472/// Parse a weekday expression (e.g., "monday", "next friday", "last tuesday").
473fn parse_weekday_expression(s: &str) -> Option<(Weekday, i8)> {
474    let s = s.trim().to_lowercase();
475    let parts: Vec<&str> = s.split_whitespace().collect();
476
477    match parts.len() {
478        1 => {
479            // Just a weekday name
480            let day = parse_weekday_name(parts.first()?)?;
481            Some((day, 0)) // 0 = this/next occurrence
482        }
483        2 => {
484            // Modifier + weekday name
485            let offset = match *parts.first()? {
486                "this" => 0,
487                "next" => 1,
488                "last" => -1,
489                _ => return None,
490            };
491            let day = parse_weekday_name(parts.get(1)?)?;
492            Some((day, offset))
493        }
494        _ => None,
495    }
496}
497
498/// Calculate the date for a weekday relative to a reference date.
499///
500/// # Arguments
501/// * `ref_date` - The reference date
502/// * `target` - The target weekday
503/// * `offset` - Week offset (0=this, 1=next week, -1=last week, etc.)
504fn resolve_weekday_date(ref_date: Date, target: Weekday, offset: i8) -> Date {
505    let ref_weekday = ref_date.weekday();
506    let ref_offset = i64::from(ref_weekday.to_monday_zero_offset());
507    let target_offset = i64::from(target.to_monday_zero_offset());
508
509    // Days until next occurrence (0 if same day)
510    let days_to_next = (target_offset - ref_offset + 7) % 7;
511    // Days since last occurrence (0 if same day)
512    let days_since_last = (ref_offset - target_offset + 7) % 7;
513
514    let total_days: i64 = if offset >= 0 {
515        // For offset 0: if same day, stay today; otherwise go to next occurrence
516        // For offset > 0: go to the occurrence offset weeks from now
517        days_to_next + i64::from(offset) * 7
518    } else {
519        // For negative offset: go backwards
520        // If same day, we need to go back at least a week
521        if days_since_last == 0 {
522            i64::from(offset) * 7
523        } else {
524            -days_since_last + (i64::from(offset) + 1) * 7
525        }
526    };
527
528    ref_date
529        .checked_add(Span::new().days(total_days))
530        .unwrap_or(ref_date)
531}
532
533#[cfg(test)]
534mod tests {
535    use jiff::civil::{date, datetime};
536    use jiff::tz::TimeZone;
537
538    use super::*;
539
540    #[test]
541    fn resolves_now_anchor_to_current_time() {
542        let now = date(2025, 1, 1)
543            .at(15, 30, 45, 0)
544            .to_zoned(TimeZone::UTC)
545            .unwrap();
546        assert_eq!(
547            DateTimeAnchor::now().resolve_at_start_of_day(&now).unwrap(),
548            now
549        );
550        assert_eq!(
551            DateTimeAnchor::now().resolve_at_end_of_day(&now).unwrap(),
552            now
553        );
554    }
555
556    #[test]
557    fn resolves_indays_anchor_to_day_boundary() {
558        let now = date(2025, 1, 1)
559            .at(15, 30, 45, 0)
560            .to_zoned(TimeZone::UTC)
561            .unwrap();
562        let anchor = DateTimeAnchor::InDays(1);
563
564        let expected = date(2025, 1, 2)
565            .at(0, 0, 0, 0)
566            .to_zoned(TimeZone::UTC)
567            .unwrap();
568
569        let parsed = anchor.resolve_at_start_of_day(&now).unwrap();
570        assert_eq!(parsed, expected);
571
572        let parsed = anchor.resolve_at_end_of_day(&now).unwrap();
573        assert!(
574            parsed
575                > date(2025, 1, 2)
576                    .at(23, 59, 59, 0)
577                    .to_zoned(TimeZone::UTC)
578                    .unwrap()
579        );
580        assert!(
581            parsed
582                < date(2025, 1, 3)
583                    .at(0, 0, 0, 0)
584                    .to_zoned(TimeZone::UTC)
585                    .unwrap()
586        );
587    }
588
589    #[test]
590    fn resolves_datetime_anchor_to_day_boundary() {
591        let now = date(2025, 1, 1)
592            .at(15, 30, 45, 0)
593            .to_zoned(TimeZone::system())
594            .unwrap();
595        let d = date(2025, 1, 5);
596        let anchor = DateTimeAnchor::DateTime(LooseDateTime::DateOnly(d));
597
598        let parsed = anchor.resolve_at_start_of_day(&now).unwrap();
599        let expected = date(2025, 1, 5)
600            .at(0, 0, 0, 0)
601            .to_zoned(TimeZone::system())
602            .unwrap();
603        assert_eq!(parsed, expected);
604
605        let parsed = anchor.resolve_at_end_of_day(&now).unwrap();
606        assert!(
607            parsed
608                > date(2025, 1, 5)
609                    .at(23, 59, 59, 0)
610                    .to_zoned(TimeZone::system())
611                    .unwrap()
612        );
613        assert!(
614            parsed
615                < date(2025, 1, 6)
616                    .at(0, 0, 0, 0)
617                    .to_zoned(TimeZone::system())
618                    .unwrap()
619        );
620    }
621
622    #[test]
623    fn resolves_various_anchor_types_correctly() {
624        let now = date(2025, 1, 1)
625            .at(15, 30, 45, 0)
626            .to_zoned(TimeZone::system())
627            .unwrap();
628        for (name, anchor, expected) in [
629            (
630                "Relative (+2h30m45s)",
631                DateTimeAnchor::Relative(2 * 60 * 60 + 30 * 60 + 45),
632                date(2025, 1, 1)
633                    .at(18, 1, 30, 0)
634                    .to_zoned(TimeZone::system())
635                    .unwrap(),
636            ),
637            (
638                "Floating",
639                {
640                    let dt = datetime(2025, 1, 5, 14, 30, 0, 0);
641                    DateTimeAnchor::DateTime(LooseDateTime::Floating(dt))
642                },
643                date(2025, 1, 5)
644                    .at(14, 30, 0, 0)
645                    .to_zoned(TimeZone::system())
646                    .unwrap(),
647            ),
648            (
649                "Local",
650                {
651                    let zoned = date(2025, 1, 5)
652                        .at(14, 30, 0, 0)
653                        .to_zoned(TimeZone::system())
654                        .unwrap();
655                    DateTimeAnchor::DateTime(LooseDateTime::Local(zoned))
656                },
657                date(2025, 1, 5)
658                    .at(14, 30, 0, 0)
659                    .to_zoned(TimeZone::system())
660                    .unwrap(),
661            ),
662        ] {
663            let parsed = anchor.resolve_at_start_of_day(&now).unwrap();
664            assert_eq!(parsed, expected, "start_of_day failed for {name}");
665
666            let parsed = anchor.resolve_at_end_of_day(&now).unwrap();
667            assert_eq!(parsed, expected, "end_of_day failed for {name}");
668        }
669    }
670
671    #[test]
672    fn resolves_time_anchor_to_specific_time() {
673        let t = time(14, 30, 0, 0);
674        let anchor = DateTimeAnchor::Time(t);
675
676        let now = date(2025, 1, 1)
677            .at(10, 0, 0, 0)
678            .to_zoned(TimeZone::system())
679            .unwrap();
680        let parsed_start = anchor.resolve_at_start_of_day(&now).unwrap();
681        let parsed_end = anchor.resolve_at_end_of_day(&now).unwrap();
682
683        // Both should have the same time (14:30) on the same date (2025-01-01)
684        assert_eq!(parsed_start.date(), now.date());
685        assert_eq!(parsed_start.time(), t);
686        assert_eq!(parsed_end.date(), now.date());
687        assert_eq!(parsed_end.time(), t);
688    }
689
690    #[test]
691    fn resolves_anchor_from_loose_datetime() {
692        let dt = |y, m, d, h, mm, s| {
693            let zoned = date(y, m, d)
694                .at(h, mm, s, 0)
695                .to_zoned(TimeZone::system())
696                .unwrap();
697            LooseDateTime::Local(zoned)
698        };
699
700        let now: LooseDateTime = LooseDateTime::Local(
701            date(2025, 1, 1)
702                .at(12, 0, 0, 0)
703                .to_zoned(TimeZone::system())
704                .unwrap(),
705        );
706
707        for (name, anchor, expected) in [
708            (
709                "AtInDays (same datetime)",
710                DateTimeAnchor::DateTime(LooseDateTime::Local(
711                    date(2025, 1, 1)
712                        .at(12, 0, 0, 0)
713                        .to_zoned(TimeZone::system())
714                        .unwrap(),
715                )),
716                LooseDateTime::Local(
717                    date(2025, 1, 1)
718                        .at(12, 0, 0, 0)
719                        .to_zoned(TimeZone::system())
720                        .unwrap(),
721                ),
722            ),
723            (
724                "Relative (+3 hours)",
725                DateTimeAnchor::Relative(3 * 60 * 60),
726                dt(2025, 1, 1, 15, 0, 0),
727            ),
728            (
729                "DateTime (absolute 10:00)",
730                DateTimeAnchor::DateTime(LooseDateTime::Local(
731                    date(2025, 1, 1)
732                        .at(10, 0, 0, 0)
733                        .to_zoned(TimeZone::system())
734                        .unwrap(),
735                )),
736                LooseDateTime::Local(
737                    date(2025, 1, 1)
738                        .at(10, 0, 0, 0)
739                        .to_zoned(TimeZone::system())
740                        .unwrap(),
741                ),
742            ),
743            (
744                "Time (10:00 today)",
745                DateTimeAnchor::Time(time(10, 0, 0, 0)),
746                LooseDateTime::Local(
747                    date(2025, 1, 1)
748                        .at(10, 0, 0, 0)
749                        .to_zoned(TimeZone::system())
750                        .unwrap(),
751                ),
752            ),
753        ] {
754            let result = anchor.resolve_at(&now);
755            assert_eq!(result, expected, "resolve_at failed for case: {name}");
756        }
757    }
758
759    #[test]
760    fn resolves_anchor_since_loose_datetime() {
761        let dt = |y, m, d, h, mm, s| {
762            LooseDateTime::Local(
763                date(y, m, d)
764                    .at(h, mm, s, 0)
765                    .to_zoned(TimeZone::system())
766                    .unwrap(),
767            )
768        };
769
770        let now: LooseDateTime = LooseDateTime::Local(
771            date(2025, 1, 1)
772                .at(12, 0, 0, 0)
773                .to_zoned(TimeZone::system())
774                .unwrap(),
775        );
776
777        for (name, anchor, expected) in [
778            (
779                "DateTime == now",
780                DateTimeAnchor::DateTime(now.clone()),
781                now.clone(),
782            ),
783            (
784                "Relative +3:25:45",
785                DateTimeAnchor::Relative(3 * 60 * 60 + 25 * 60 + 45),
786                dt(2025, 1, 1, 15, 25, 45),
787            ),
788            (
789                "Explicit DateTime 10:00",
790                DateTimeAnchor::DateTime(dt(2025, 1, 1, 10, 0, 0)),
791                dt(2025, 1, 1, 10, 0, 0),
792            ),
793            (
794                "Time before now -> tomorrow 10:00",
795                DateTimeAnchor::Time(time(10, 0, 0, 0)),
796                dt(2025, 1, 2, 10, 0, 0),
797            ),
798            (
799                "Time after now -> today 14:00",
800                DateTimeAnchor::Time(time(14, 0, 0, 0)),
801                dt(2025, 1, 1, 14, 0, 0),
802            ),
803        ] {
804            let result = anchor.resolve_since(&now).unwrap();
805            assert_eq!(result, expected, "case failed: {name}");
806        }
807    }
808
809    #[test]
810    fn resolves_anchor_since_zoned() {
811        let now = date(2025, 1, 1)
812            .at(12, 0, 0, 0)
813            .to_zoned(TimeZone::system())
814            .unwrap();
815
816        for (name, anchor, expected) in [
817            (
818                "DateTimeAnchor::DateTime (same datetime)",
819                DateTimeAnchor::DateTime(LooseDateTime::Local(now.clone())),
820                LooseDateTime::Local(now.clone()),
821            ),
822            (
823                "DateTimeAnchor::Relative (3h25m45s later)",
824                DateTimeAnchor::Relative(3 * 60 * 60 + 25 * 60 + 45),
825                LooseDateTime::Local(
826                    date(2025, 1, 1)
827                        .at(15, 25, 45, 0)
828                        .to_zoned(TimeZone::system())
829                        .unwrap(),
830                ),
831            ),
832            (
833                "DateTimeAnchor::DateTime (specific datetime before now)",
834                DateTimeAnchor::DateTime(LooseDateTime::Local(
835                    date(2025, 1, 1)
836                        .at(10, 0, 0, 0)
837                        .to_zoned(TimeZone::system())
838                        .unwrap(),
839                )),
840                LooseDateTime::Local(
841                    date(2025, 1, 1)
842                        .at(10, 0, 0, 0)
843                        .to_zoned(TimeZone::system())
844                        .unwrap(),
845                ),
846            ),
847            (
848                "DateTimeAnchor::Time (before now → tomorrow)",
849                DateTimeAnchor::Time(time(10, 0, 0, 0)),
850                LooseDateTime::Local(
851                    date(2025, 1, 2)
852                        .at(10, 0, 0, 0)
853                        .to_zoned(TimeZone::system())
854                        .unwrap(),
855                ),
856            ),
857            (
858                "DateTimeAnchor::Time (after now → today)",
859                DateTimeAnchor::Time(time(14, 0, 0, 0)),
860                LooseDateTime::Local(
861                    date(2025, 1, 1)
862                        .at(14, 0, 0, 0)
863                        .to_zoned(TimeZone::system())
864                        .unwrap(),
865                ),
866            ),
867        ] {
868            let result = anchor.resolve_since_zoned(&now).unwrap();
869            assert_eq!(result, expected, "failed: {name} → resolve_since_zoned");
870        }
871    }
872
873    #[test]
874    fn parses_string_to_datetime_anchor() {
875        for (s, expected) in [
876            // Keywords
877            ("now", DateTimeAnchor::now()),
878            ("today", DateTimeAnchor::today()),
879            ("yesterday", DateTimeAnchor::yesterday()),
880            ("tomorrow", DateTimeAnchor::tomorrow()),
881            // Date only
882            (
883                "2025-01-15",
884                DateTimeAnchor::DateTime(LooseDateTime::DateOnly(date(2025, 1, 15))),
885            ),
886            // DateTime
887            (
888                "2025-01-15 14:30",
889                DateTimeAnchor::DateTime(LooseDateTime::Local(
890                    date(2025, 1, 15)
891                        .at(14, 30, 0, 0)
892                        .to_zoned(TimeZone::system())
893                        .unwrap(),
894                )),
895            ),
896            // Time only
897            ("14:30", DateTimeAnchor::Time(time(14, 30, 0, 0))),
898            // Month-day (year resolved later)
899            ("8-10", DateTimeAnchor::MonthDay(8, 10)),
900            ("12-25", DateTimeAnchor::MonthDay(12, 25)),
901            ("1-1", DateTimeAnchor::MonthDay(1, 1)),
902        ] {
903            let anchor: DateTimeAnchor = s.parse().unwrap();
904            assert_eq!(anchor, expected, "Failed to parse '{s}'");
905        }
906    }
907
908    #[test]
909    fn returns_error_for_invalid_string() {
910        let result = DateTimeAnchor::from_str("invalid");
911        assert!(result.is_err());
912        assert!(result.unwrap_err().contains("Invalid datetime anchor"));
913    }
914
915    #[test]
916    fn parses_seconds_and_minutes_durations() {
917        for (tests, expected) in [
918            (
919                [
920                    "10s",
921                    "10sec",
922                    "10seconds",
923                    "   10   s   ",
924                    "   10   sec   ",
925                    "   10   seconds   ",
926                ],
927                DateTimeAnchor::Relative(10),
928            ),
929            (
930                [
931                    "10m",
932                    "10min",
933                    "10minutes",
934                    "   10   m   ",
935                    "   10   min   ",
936                    "   10   minutes   ",
937                ],
938                DateTimeAnchor::Relative(10 * 60),
939            ),
940        ] {
941            for s in tests {
942                let anchor: DateTimeAnchor = s.parse().unwrap();
943                assert_eq!(anchor, expected, "Failed to parse '{s}'");
944
945                // No "in " prefix allowed for seconds
946                let prefix_in = DateTimeAnchor::from_str(&format!("in {s}"));
947                assert!(prefix_in.is_err());
948
949                // No uppercase allowed for seconds
950                let uppercase = DateTimeAnchor::from_str(&s.to_uppercase());
951                assert!(uppercase.is_err());
952            }
953        }
954    }
955
956    #[test]
957    fn parses_hours_and_days_durations() {
958        for (tests, expected) in [
959            (
960                [
961                    "in 10hours",
962                    "in 10H",
963                    "   IN   10   hours   ",
964                    "10hours",
965                    "10 HOURS",
966                    "   10   hours   ",
967                    "10h",
968                    "10 H",
969                    "   10   h   ",
970                ],
971                DateTimeAnchor::Relative(10 * 60 * 60),
972            ),
973            (
974                [
975                    "in 10days",
976                    "in 10D",
977                    "   IN   10   days   ",
978                    "10days",
979                    "10 DAYS",
980                    "   10   days   ",
981                    "10d",
982                    "10 D",
983                    "   10   d   ",
984                ],
985                DateTimeAnchor::InDays(10),
986            ),
987        ] {
988            for s in tests {
989                let anchor: DateTimeAnchor = s.parse().unwrap();
990                assert_eq!(anchor, expected, "Failed to parse '{s}'");
991            }
992        }
993    }
994
995    #[test]
996    fn suggests_next_available_time_slot() {
997        for (hour, min, expected_hour, description) in [
998            (8, 30, 9, "Before 9 AM, should suggest 9 AM"),
999            (
1000                10,
1001                30,
1002                13,
1003                "After 9 AM but before 1 PM, should suggest 1 PM",
1004            ),
1005            (
1006                14,
1007                30,
1008                18,
1009                "After 1 PM but before 6 PM, should suggest 6 PM",
1010            ),
1011            (9, 0, 13, "Exactly at 9 AM, should suggest 1 PM"),
1012            (13, 0, 18, "Exactly at 1 PM, should suggest 6 PM"),
1013        ] {
1014            let now = datetime(2025, 1, 1, hour, min, 0, 0);
1015            let result = next_suggested_time(&now);
1016            let zoned = date(2025, 1, 1)
1017                .at(expected_hour, 0, 0, 0)
1018                .to_zoned(TimeZone::system())
1019                .unwrap();
1020            let expected = LooseDateTime::Local(zoned);
1021            assert_eq!(result, expected, "{description}");
1022        }
1023    }
1024
1025    #[test]
1026    fn suggests_dateonly_after_business_hours() {
1027        // After 6 PM, should suggest DateOnly (next day)
1028        let now = datetime(2025, 1, 1, 19, 30, 0, 0);
1029        let result = next_suggested_time(&now);
1030        let expected = LooseDateTime::DateOnly(date(2025, 1, 1));
1031        assert_eq!(result, expected, "After 6 PM, should suggest DateOnly");
1032
1033        // Exactly at 6 PM, should suggest DateOnly (next day)
1034        let now = datetime(2025, 1, 1, 18, 0, 0, 0);
1035        let result = next_suggested_time(&now);
1036        let expected = LooseDateTime::DateOnly(date(2025, 1, 1));
1037        assert_eq!(result, expected, "Exactly at 6 PM, should suggest DateOnly");
1038    }
1039
1040    #[test]
1041    fn resolves_monthday_with_current_year() {
1042        let now = date(2025, 6, 15)
1043            .at(12, 0, 0, 0)
1044            .to_zoned(TimeZone::system())
1045            .unwrap();
1046
1047        let anchor = DateTimeAnchor::MonthDay(8, 10);
1048
1049        // resolve_at_start_of_day
1050        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1051        assert_eq!(result.date(), date(2025, 8, 10));
1052        assert_eq!(result.time(), time(0, 0, 0, 0));
1053
1054        // resolve_at_end_of_day
1055        let result = anchor.resolve_at_end_of_day(&now).unwrap();
1056        assert_eq!(result.date(), date(2025, 8, 10));
1057        assert!(result.time() > time(23, 59, 59, 0));
1058    }
1059
1060    #[test]
1061    fn resolves_monthday_since_loose_datetime() {
1062        let now: LooseDateTime = LooseDateTime::Local(
1063            date(2025, 6, 15)
1064                .at(12, 0, 0, 0)
1065                .to_zoned(TimeZone::system())
1066                .unwrap(),
1067        );
1068
1069        let anchor = DateTimeAnchor::MonthDay(12, 25);
1070        let result = anchor.resolve_since(&now).unwrap();
1071        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 12, 25)));
1072    }
1073
1074    #[test]
1075    fn resolves_monthday_since_zoned() {
1076        let now = date(2025, 6, 15)
1077            .at(12, 0, 0, 0)
1078            .to_zoned(TimeZone::system())
1079            .unwrap();
1080
1081        let anchor = DateTimeAnchor::MonthDay(1, 1);
1082        let result = anchor.resolve_since_zoned(&now).unwrap();
1083        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 1)));
1084    }
1085
1086    #[test]
1087    fn validates_month_day_combinations() {
1088        // Valid month-day combinations
1089        for (s, expected) in [
1090            ("1-1", DateTimeAnchor::MonthDay(1, 1)),
1091            ("1-31", DateTimeAnchor::MonthDay(1, 31)),
1092            ("2-28", DateTimeAnchor::MonthDay(2, 28)),
1093            ("2-29", DateTimeAnchor::MonthDay(2, 29)), // Leap year handled at resolution
1094            ("3-31", DateTimeAnchor::MonthDay(3, 31)),
1095            ("4-30", DateTimeAnchor::MonthDay(4, 30)),
1096            ("6-30", DateTimeAnchor::MonthDay(6, 30)),
1097            ("9-30", DateTimeAnchor::MonthDay(9, 30)),
1098            ("11-30", DateTimeAnchor::MonthDay(11, 30)),
1099            ("12-31", DateTimeAnchor::MonthDay(12, 31)),
1100        ] {
1101            let anchor: DateTimeAnchor = s.parse().unwrap();
1102            assert_eq!(anchor, expected, "Failed to parse valid '{s}'");
1103        }
1104
1105        // Invalid month-day combinations
1106        for s in [
1107            "0-1",   // Month 0
1108            "13-1",  // Month 13
1109            "1-0",   // Day 0
1110            "1-32",  // January 32
1111            "2-30",  // February 30
1112            "4-31",  // April 31
1113            "6-31",  // June 31
1114            "9-31",  // September 31
1115            "11-31", // November 31
1116        ] {
1117            let result = DateTimeAnchor::from_str(s);
1118            assert!(result.is_err(), "Should reject invalid '{s}'");
1119        }
1120    }
1121
1122    #[test]
1123    fn parses_weekday_names() {
1124        // Full names (case-insensitive)
1125        for (s, expected) in [
1126            ("monday", Weekday::Monday),
1127            ("Tuesday", Weekday::Tuesday),
1128            ("WEDNESDAY", Weekday::Wednesday),
1129            ("thursday", Weekday::Thursday),
1130            ("Friday", Weekday::Friday),
1131            ("saturday", Weekday::Saturday),
1132            ("Sunday", Weekday::Sunday),
1133        ] {
1134            let anchor: DateTimeAnchor = s.parse().unwrap();
1135            assert_eq!(
1136                anchor,
1137                DateTimeAnchor::Weekday {
1138                    day: expected,
1139                    offset: 0
1140                },
1141                "Failed to parse '{s}'"
1142            );
1143        }
1144
1145        // Abbreviations (case-insensitive)
1146        for (s, expected) in [
1147            ("mon", Weekday::Monday),
1148            ("TUE", Weekday::Tuesday),
1149            ("Wed", Weekday::Wednesday),
1150            ("thu", Weekday::Thursday),
1151            ("FRI", Weekday::Friday),
1152            ("sat", Weekday::Saturday),
1153            ("SUN", Weekday::Sunday),
1154        ] {
1155            let anchor: DateTimeAnchor = s.parse().unwrap();
1156            assert_eq!(
1157                anchor,
1158                DateTimeAnchor::Weekday {
1159                    day: expected,
1160                    offset: 0
1161                },
1162                "Failed to parse '{s}'"
1163            );
1164        }
1165    }
1166
1167    #[test]
1168    fn parses_weekday_with_modifiers() {
1169        // "this" modifier
1170        for (s, expected) in [
1171            ("this monday", Weekday::Monday),
1172            ("this tuesday", Weekday::Tuesday),
1173            ("THIS FRIDAY", Weekday::Friday),
1174        ] {
1175            let anchor: DateTimeAnchor = s.parse().unwrap();
1176            assert_eq!(
1177                anchor,
1178                DateTimeAnchor::Weekday {
1179                    day: expected,
1180                    offset: 0
1181                },
1182                "Failed to parse '{s}'"
1183            );
1184        }
1185
1186        // "next" modifier
1187        for (s, expected) in [
1188            ("next monday", Weekday::Monday),
1189            ("next tuesday", Weekday::Tuesday),
1190            ("NEXT FRIDAY", Weekday::Friday),
1191        ] {
1192            let anchor: DateTimeAnchor = s.parse().unwrap();
1193            assert_eq!(
1194                anchor,
1195                DateTimeAnchor::Weekday {
1196                    day: expected,
1197                    offset: 1
1198                },
1199                "Failed to parse '{s}'"
1200            );
1201        }
1202
1203        // "last" modifier
1204        for (s, expected) in [
1205            ("last monday", Weekday::Monday),
1206            ("last tuesday", Weekday::Tuesday),
1207            ("LAST FRIDAY", Weekday::Friday),
1208        ] {
1209            let anchor: DateTimeAnchor = s.parse().unwrap();
1210            assert_eq!(
1211                anchor,
1212                DateTimeAnchor::Weekday {
1213                    day: expected,
1214                    offset: -1
1215                },
1216                "Failed to parse '{s}'"
1217            );
1218        }
1219    }
1220
1221    #[test]
1222    fn resolves_weekday_this_offset_same_day() {
1223        // Monday 2025-01-06, asking for "monday" (offset 0)
1224        let now = date(2025, 1, 6)
1225            .at(12, 0, 0, 0)
1226            .to_zoned(TimeZone::UTC)
1227            .unwrap();
1228
1229        let anchor = DateTimeAnchor::Weekday {
1230            day: Weekday::Monday,
1231            offset: 0,
1232        };
1233
1234        // Should return today (same day)
1235        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1236        assert_eq!(result.date(), date(2025, 1, 6));
1237    }
1238
1239    #[test]
1240    fn resolves_weekday_this_offset_different_day() {
1241        // Monday 2025-01-06, asking for "friday" (offset 0)
1242        let now = date(2025, 1, 6)
1243            .at(12, 0, 0, 0)
1244            .to_zoned(TimeZone::UTC)
1245            .unwrap();
1246
1247        let anchor = DateTimeAnchor::Weekday {
1248            day: Weekday::Friday,
1249            offset: 0,
1250        };
1251
1252        // Should return Friday of this week (2025-01-10)
1253        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1254        assert_eq!(result.date(), date(2025, 1, 10));
1255    }
1256
1257    #[test]
1258    fn resolves_weekday_next_offset() {
1259        // Monday 2025-01-06, asking for "next monday" (offset 1)
1260        let now = date(2025, 1, 6)
1261            .at(12, 0, 0, 0)
1262            .to_zoned(TimeZone::UTC)
1263            .unwrap();
1264
1265        let anchor = DateTimeAnchor::Weekday {
1266            day: Weekday::Monday,
1267            offset: 1,
1268        };
1269
1270        // Should return next week's Monday (2025-01-13)
1271        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1272        assert_eq!(result.date(), date(2025, 1, 13));
1273    }
1274
1275    #[test]
1276    fn resolves_weekday_last_offset() {
1277        // Monday 2025-01-06, asking for "last friday" (offset -1)
1278        let now = date(2025, 1, 6)
1279            .at(12, 0, 0, 0)
1280            .to_zoned(TimeZone::UTC)
1281            .unwrap();
1282
1283        let anchor = DateTimeAnchor::Weekday {
1284            day: Weekday::Friday,
1285            offset: -1,
1286        };
1287
1288        // Should return previous Friday (2025-01-03)
1289        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1290        assert_eq!(result.date(), date(2025, 1, 3));
1291    }
1292
1293    #[test]
1294    fn resolves_weekday_last_offset_same_day() {
1295        // Monday 2025-01-06, asking for "last monday" (offset -1)
1296        let now = date(2025, 1, 6)
1297            .at(12, 0, 0, 0)
1298            .to_zoned(TimeZone::UTC)
1299            .unwrap();
1300
1301        let anchor = DateTimeAnchor::Weekday {
1302            day: Weekday::Monday,
1303            offset: -1,
1304        };
1305
1306        // Should return previous Monday (2024-12-30)
1307        let result = anchor.resolve_at_start_of_day(&now).unwrap();
1308        assert_eq!(result.date(), date(2024, 12, 30));
1309    }
1310
1311    #[test]
1312    fn resolves_weekday_with_end_of_day() {
1313        // Monday 2025-01-06, asking for "friday" (offset 0)
1314        let now = date(2025, 1, 6)
1315            .at(12, 0, 0, 0)
1316            .to_zoned(TimeZone::UTC)
1317            .unwrap();
1318
1319        let anchor = DateTimeAnchor::Weekday {
1320            day: Weekday::Friday,
1321            offset: 0,
1322        };
1323
1324        let result = anchor.resolve_at_end_of_day(&now).unwrap();
1325        assert_eq!(result.date(), date(2025, 1, 10));
1326        assert!(result.time() > time(23, 59, 59, 0));
1327    }
1328
1329    #[test]
1330    fn resolves_weekday_since_loose_datetime() {
1331        let now: LooseDateTime = LooseDateTime::Local(
1332            date(2025, 1, 6) // Monday
1333                .at(12, 0, 0, 0)
1334                .to_zoned(TimeZone::system())
1335                .unwrap(),
1336        );
1337
1338        // This Friday (offset 0)
1339        let anchor = DateTimeAnchor::Weekday {
1340            day: Weekday::Friday,
1341            offset: 0,
1342        };
1343        let result = anchor.resolve_since(&now).unwrap();
1344        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 10)));
1345
1346        // Next Monday (offset 1)
1347        let anchor = DateTimeAnchor::Weekday {
1348            day: Weekday::Monday,
1349            offset: 1,
1350        };
1351        let result = anchor.resolve_since(&now).unwrap();
1352        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 13)));
1353
1354        // Last Friday (offset -1)
1355        let anchor = DateTimeAnchor::Weekday {
1356            day: Weekday::Friday,
1357            offset: -1,
1358        };
1359        let result = anchor.resolve_since(&now).unwrap();
1360        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 3)));
1361    }
1362
1363    #[test]
1364    fn resolves_weekday_since_zoned() {
1365        let now = date(2025, 1, 6) // Monday
1366            .at(12, 0, 0, 0)
1367            .to_zoned(TimeZone::system())
1368            .unwrap();
1369
1370        // This Friday (offset 0)
1371        let anchor = DateTimeAnchor::Weekday {
1372            day: Weekday::Friday,
1373            offset: 0,
1374        };
1375        let result = anchor.resolve_since_zoned(&now).unwrap();
1376        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 10)));
1377
1378        // Next Monday (offset 1)
1379        let anchor = DateTimeAnchor::Weekday {
1380            day: Weekday::Monday,
1381            offset: 1,
1382        };
1383        let result = anchor.resolve_since_zoned(&now).unwrap();
1384        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 13)));
1385
1386        // Last Friday (offset -1)
1387        let anchor = DateTimeAnchor::Weekday {
1388            day: Weekday::Friday,
1389            offset: -1,
1390        };
1391        let result = anchor.resolve_since_zoned(&now).unwrap();
1392        assert_eq!(result, LooseDateTime::DateOnly(date(2025, 1, 3)));
1393    }
1394
1395    #[test]
1396    fn rejects_invalid_weekday_expressions() {
1397        for s in [
1398            "mondayy",
1399            "nex monday", // typo in "next"
1400            "next mond",  // invalid abbreviation
1401            "nextnext monday",
1402            "monday next",
1403            "next",
1404        ] {
1405            let result = DateTimeAnchor::from_str(s);
1406            assert!(result.is_err(), "Should reject invalid '{s}'");
1407        }
1408    }
1409}