1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum DateTimeAnchor {
18 InDays(i64),
20
21 Relative(i64),
23
24 DateTime(LooseDateTime),
26
27 Time(Time),
29}
30
31impl DateTimeAnchor {
32 #[must_use]
34 pub fn now() -> Self {
35 DateTimeAnchor::Relative(0)
36 }
37
38 #[must_use]
40 pub fn today() -> Self {
41 DateTimeAnchor::InDays(0)
42 }
43
44 #[must_use]
46 pub fn tomorrow() -> Self {
47 DateTimeAnchor::InDays(1)
48 }
49
50 #[must_use]
52 pub fn yesterday() -> Self {
53 DateTimeAnchor::InDays(-1)
54 }
55
56 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 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 #[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 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 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 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 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 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 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 if let Ok(d) = Date::strptime("%Y-%m-%d", t) {
236 return Ok(Self::DateTime(LooseDateTime::DateOnly(d)));
237 }
238
239 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 if let Ok(time) = Time::strptime("%H:%M", t) {
246 return Ok(Self::Time(time));
247 }
248
249 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*$"); parse_with_regex!(parse_minutes, r"^\s*(\d+)\s*m(?:in|inutes)?\s*$"); parse_with_regex!(parse_hours, r"(?i)^\s*(?:in\s*)?(\d+)\s*h(?:ours)?\s*$"); parse_with_regex!(parse_days, r"(?i)^\s*(?:in\s*)?(\d+)\s*d(?:ays)?\s*$"); const 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 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 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 ("now", DateTimeAnchor::now()),
688 ("today", DateTimeAnchor::today()),
689 ("yesterday", DateTimeAnchor::yesterday()),
690 ("tomorrow", DateTimeAnchor::tomorrow()),
691 (
693 "2025-01-15",
694 DateTimeAnchor::DateTime(LooseDateTime::DateOnly(date(2025, 1, 15))),
695 ),
696 (
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 ("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 let prefix_in = DateTimeAnchor::from_str(&format!("in {s}"));
753 assert!(prefix_in.is_err());
754
755 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 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 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}