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