1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum DateTimeAnchor {
19 InDays(i64),
21 Relative(i64),
23 DateTime(LooseDateTime),
25 Time(Time),
27 MonthDay(i8, i8),
29 Weekday {
31 day: Weekday,
33 offset: i8,
41 },
42}
43
44impl DateTimeAnchor {
45 #[must_use]
47 pub fn now() -> Self {
48 DateTimeAnchor::Relative(0)
49 }
50
51 #[must_use]
53 pub fn today() -> Self {
54 DateTimeAnchor::InDays(0)
55 }
56
57 #[must_use]
59 pub fn tomorrow() -> Self {
60 DateTimeAnchor::InDays(1)
61 }
62
63 #[must_use]
65 pub fn yesterday() -> Self {
66 DateTimeAnchor::InDays(-1)
67 }
68
69 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 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 #[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(), }
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 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 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 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 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 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 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 if let Ok(d) = Date::strptime("%Y-%m-%d", t) {
292 return Ok(Self::DateTime(LooseDateTime::DateOnly(d)));
293 }
294
295 if let Some((month, day)) = parse_month_day(t) {
297 return Ok(Self::MonthDay(month, day));
298 }
299
300 if let Ok(time) = Time::strptime("%H:%M", t) {
302 return Ok(Self::Time(time));
303 }
304
305 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 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
369macro_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*$"); parse_with_regex!(parse_minutes, r"^\s*(\d+)\s*m(?:in|inutes)?\s*$"); parse_with_regex_and_deprecated_in!(
398 parse_hours,
399 r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$",
400 "h"
401); parse_with_regex_and_deprecated_in!(parse_days, r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$", "d"); const 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
440fn is_valid_month_day(month: i8, day: i8) -> bool {
442 if !(1..=12).contains(&month) || day < 1 {
444 return false;
445 }
446
447 let max_day = match month {
449 2 => 29, 4 | 6 | 9 | 11 => 30, _ => 31, };
453
454 day <= max_day
455}
456
457fn 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
472fn 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 let day = parse_weekday_name(parts.first()?)?;
481 Some((day, 0)) }
483 2 => {
484 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
498fn 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 let days_to_next = (target_offset - ref_offset + 7) % 7;
511 let days_since_last = (ref_offset - target_offset + 7) % 7;
513
514 let total_days: i64 = if offset >= 0 {
515 days_to_next + i64::from(offset) * 7
518 } else {
519 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 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 ("now", DateTimeAnchor::now()),
878 ("today", DateTimeAnchor::today()),
879 ("yesterday", DateTimeAnchor::yesterday()),
880 ("tomorrow", DateTimeAnchor::tomorrow()),
881 (
883 "2025-01-15",
884 DateTimeAnchor::DateTime(LooseDateTime::DateOnly(date(2025, 1, 15))),
885 ),
886 (
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 ("14:30", DateTimeAnchor::Time(time(14, 30, 0, 0))),
898 ("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 let prefix_in = DateTimeAnchor::from_str(&format!("in {s}"));
947 assert!(prefix_in.is_err());
948
949 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 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 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 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 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 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)), ("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 for s in [
1107 "0-1", "13-1", "1-0", "1-32", "2-30", "4-31", "6-31", "9-31", "11-31", ] {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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) .at(12, 0, 0, 0)
1334 .to_zoned(TimeZone::system())
1335 .unwrap(),
1336 );
1337
1338 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 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 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) .at(12, 0, 0, 0)
1367 .to_zoned(TimeZone::system())
1368 .unwrap();
1369
1370 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 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 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", "next mond", "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}