1use std::sync::LazyLock;
2
3use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveTime, TimeZone, Weekday};
4use doing_error::{Error, Result};
5use regex::Regex;
6
7use crate::duration::parse_duration;
8
9static RE_AGO: LazyLock<Regex> = LazyLock::new(|| {
10 Regex::new(r"^(\w+)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mo)\s+ago$").unwrap()
11});
12static RE_DAY_OF_WEEK: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"^(last|next|this)?\s*(mon|tue|wed|thu|fri|sat|sun)\w*$").unwrap());
14static RE_ISO_DATE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{4})-(\d{2})-(\d{2})$").unwrap());
15static RE_ISO_DATETIME: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$").unwrap());
17static RE_TIME_12H: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$").unwrap());
18static RE_TIME_24H: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{1,2}):(\d{2})$").unwrap());
19static RE_US_DATE_LONG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{1,2})/(\d{1,2})/(\d{4})$").unwrap());
20static RE_US_DATE_NO_YEAR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{1,2})/(\d{1,2})$").unwrap());
21static RE_US_DATE_SHORT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d{1,2})/(\d{1,2})/(\d{2})$").unwrap());
22
23pub fn chronify(input: &str) -> Result<DateTime<Local>> {
32 let input = input.trim().to_lowercase();
33
34 if input.is_empty() {
35 return Err(Error::InvalidTimeExpression("empty input".into()));
36 }
37
38 if let Some(dt) = parse_relative(&input) {
39 return Ok(dt);
40 }
41
42 if let Some(dt) = parse_day_of_week(&input) {
43 return Ok(dt);
44 }
45
46 if let Some(dt) = parse_time_only(&input) {
47 return Ok(dt);
48 }
49
50 if let Some(dt) = parse_absolute(&input) {
51 return Ok(dt);
52 }
53
54 if let Some(dt) = parse_combined(&input) {
55 return Ok(dt);
56 }
57
58 if let Some(dt) = parse_shorthand_duration(&input) {
59 return Ok(dt);
60 }
61
62 Err(Error::InvalidTimeExpression(format!("{input:?}")))
63}
64
65fn apply_time_to_date(dt: DateTime<Local>, time: NaiveTime) -> Option<DateTime<Local>> {
67 Local.from_local_datetime(&dt.date_naive().and_time(time)).earliest()
68}
69
70fn beginning_of_day(date: NaiveDate) -> DateTime<Local> {
73 if let Some(dt) = Local.from_local_datetime(&date.and_time(NaiveTime::MIN)).earliest() {
74 return dt;
75 }
76 for hour in 1..=12 {
77 if let Some(dt) = Local
78 .from_local_datetime(&date.and_hms_opt(hour, 0, 0).unwrap())
79 .earliest()
80 {
81 return dt;
82 }
83 }
84 date.and_time(NaiveTime::MIN).and_utc().with_timezone(&Local)
86}
87
88fn parse_absolute(input: &str) -> Option<DateTime<Local>> {
91 if let Some(caps) = RE_ISO_DATETIME.captures(input) {
93 let year: i32 = caps[1].parse().ok()?;
94 let month: u32 = caps[2].parse().ok()?;
95 let day: u32 = caps[3].parse().ok()?;
96 let hour: u32 = caps[4].parse().ok()?;
97 let min: u32 = caps[5].parse().ok()?;
98
99 let date = NaiveDate::from_ymd_opt(year, month, day)?;
100 let time = NaiveTime::from_hms_opt(hour, min, 0)?;
101 return Local.from_local_datetime(&date.and_time(time)).earliest();
102 }
103
104 if let Some(caps) = RE_ISO_DATE.captures(input) {
106 let year: i32 = caps[1].parse().ok()?;
107 let month: u32 = caps[2].parse().ok()?;
108 let day: u32 = caps[3].parse().ok()?;
109
110 let date = NaiveDate::from_ymd_opt(year, month, day)?;
111 return Some(beginning_of_day(date));
112 }
113
114 if let Some(caps) = RE_US_DATE_LONG.captures(input) {
116 let month: u32 = caps[1].parse().ok()?;
117 let day: u32 = caps[2].parse().ok()?;
118 let year: i32 = caps[3].parse().ok()?;
119
120 let date = NaiveDate::from_ymd_opt(year, month, day)?;
121 return Some(beginning_of_day(date));
122 }
123
124 if let Some(caps) = RE_US_DATE_SHORT.captures(input) {
126 let month: u32 = caps[1].parse().ok()?;
127 let day: u32 = caps[2].parse().ok()?;
128 let short_year: i32 = caps[3].parse().ok()?;
129 let year = 2000 + short_year;
130
131 let date = NaiveDate::from_ymd_opt(year, month, day)?;
132 return Some(beginning_of_day(date));
133 }
134
135 if let Some(caps) = RE_US_DATE_NO_YEAR.captures(input) {
137 let month: u32 = caps[1].parse().ok()?;
138 let day: u32 = caps[2].parse().ok()?;
139 let today = Local::now().date_naive();
140 let year = today.year();
141
142 let date = NaiveDate::from_ymd_opt(year, month, day)?;
143 let date = if date > today {
145 NaiveDate::from_ymd_opt(year - 1, month, day)?
146 } else {
147 date
148 };
149 return Some(beginning_of_day(date));
150 }
151
152 None
153}
154
155fn parse_ago(input: &str, now: DateTime<Local>) -> Option<DateTime<Local>> {
158 let caps = RE_AGO.captures(input)?;
159
160 let amount = parse_number(&caps[1])?;
161 let unit = &caps[2];
162
163 let duration = match unit {
164 u if u.starts_with("mi") || u == "m" => Duration::minutes(amount),
165 u if u.starts_with('h') => Duration::hours(amount),
166 u if u.starts_with('d') => Duration::days(amount),
167 u if u.starts_with('w') => Duration::weeks(amount),
168 u if u.starts_with("mo") => Duration::days(amount * 30),
169 _ => return None,
170 };
171
172 Some(now - duration)
173}
174
175fn parse_combined(input: &str) -> Option<DateTime<Local>> {
178 let (date_part, time_part) = if let Some((d, t)) = input.split_once(" at ") {
180 (d.trim(), t.trim())
181 } else {
182 let last_space = input.rfind(' ')?;
184 let (d, t) = input.split_at(last_space);
185 (d.trim(), t.trim())
186 };
187
188 let time = resolve_time_expression(time_part)?;
189
190 let base_date = if let Some(dt) = parse_relative(date_part) {
192 dt
193 } else if let Some(dt) = parse_day_of_week(date_part) {
194 dt
195 } else if let Some(dt) = parse_absolute(date_part) {
196 dt
197 } else {
198 return None;
199 };
200
201 apply_time_to_date(base_date, time)
202}
203
204fn parse_day_of_week(input: &str) -> Option<DateTime<Local>> {
207 let now = Local::now();
208 let caps = RE_DAY_OF_WEEK.captures(input)?;
209
210 let direction = caps.get(1).map(|m| m.as_str());
211 let weekday = parse_weekday(&caps[2])?;
212
213 Some(beginning_of_day(resolve_weekday(now, weekday, direction).date_naive()))
214}
215
216fn parse_number(s: &str) -> Option<i64> {
219 match s {
220 "one" | "a" | "an" => Some(1),
221 "two" => Some(2),
222 "three" => Some(3),
223 "four" => Some(4),
224 "five" => Some(5),
225 "six" => Some(6),
226 "seven" => Some(7),
227 "eight" => Some(8),
228 "nine" => Some(9),
229 "ten" => Some(10),
230 "eleven" => Some(11),
231 "twelve" => Some(12),
232 _ => s.parse().ok(),
233 }
234}
235
236fn parse_relative(input: &str) -> Option<DateTime<Local>> {
239 let now = Local::now();
240
241 match input {
242 "now" => return Some(now),
243 "today" => return Some(beginning_of_day(now.date_naive())),
244 "yesterday" => return Some(beginning_of_day((now - Duration::days(1)).date_naive())),
245 "tomorrow" => return Some(beginning_of_day((now + Duration::days(1)).date_naive())),
246 _ => {}
247 }
248
249 parse_ago(input, now)
250}
251
252fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
255 let duration = parse_duration(input).ok()?;
256 Some(Local::now() - duration)
257}
258
259fn parse_time_only(input: &str) -> Option<DateTime<Local>> {
263 let time = resolve_time_expression(input)?;
264 let now = Local::now();
265 apply_time_to_date(now, time)
266}
267
268fn parse_weekday(s: &str) -> Option<Weekday> {
270 match s {
271 s if s.starts_with("mon") => Some(Weekday::Mon),
272 s if s.starts_with("tue") => Some(Weekday::Tue),
273 s if s.starts_with("wed") => Some(Weekday::Wed),
274 s if s.starts_with("thu") => Some(Weekday::Thu),
275 s if s.starts_with("fri") => Some(Weekday::Fri),
276 s if s.starts_with("sat") => Some(Weekday::Sat),
277 s if s.starts_with("sun") => Some(Weekday::Sun),
278 _ => None,
279 }
280}
281
282fn resolve_time_expression(input: &str) -> Option<NaiveTime> {
285 match input {
286 "noon" => return NaiveTime::from_hms_opt(12, 0, 0),
287 "midnight" => return NaiveTime::from_hms_opt(0, 0, 0),
288 _ => {}
289 }
290
291 if let Some(caps) = RE_TIME_12H.captures(input) {
293 let mut hour: u32 = caps[1].parse().ok()?;
294 let min: u32 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
295 let period = &caps[3];
296
297 if hour > 12 || min > 59 {
298 return None;
299 }
300
301 if period == "am" && hour == 12 {
302 hour = 0;
303 } else if period == "pm" && hour != 12 {
304 hour += 12;
305 }
306
307 return NaiveTime::from_hms_opt(hour, min, 0);
308 }
309
310 if let Some(caps) = RE_TIME_24H.captures(input) {
312 let hour: u32 = caps[1].parse().ok()?;
313 let min: u32 = caps[2].parse().ok()?;
314
315 if hour > 23 || min > 59 {
316 return None;
317 }
318
319 return NaiveTime::from_hms_opt(hour, min, 0);
320 }
321
322 None
323}
324
325fn resolve_weekday(now: DateTime<Local>, target: Weekday, direction: Option<&str>) -> DateTime<Local> {
328 let current = now.weekday();
329 let current_num = current.num_days_from_monday() as i64;
330 let target_num = target.num_days_from_monday() as i64;
331
332 match direction {
333 Some("next") => {
334 let d = target_num - current_num;
335 let diff = if d <= 0 { d + 7 } else { d };
336 now + Duration::days(diff)
337 }
338 Some("this") => {
339 let d = target_num - current_num;
342 if d >= 0 {
343 now + Duration::days(d)
344 } else {
345 now - Duration::days(-d)
346 }
347 }
348 _ => {
349 let d = current_num - target_num;
352 let diff = if d <= 0 { d + 7 } else { d };
353 now - Duration::days(diff)
354 }
355 }
356}
357
358#[cfg(test)]
359mod test {
360 use super::*;
361
362 mod beginning_of_day {
363 use super::*;
364
365 #[test]
366 fn it_does_not_panic_on_dst_gap_dates() {
367 let dates = [
371 NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
372 NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
373 NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
374 ];
375 for date in &dates {
376 let result = beginning_of_day(*date);
377 assert_eq!(result.date_naive(), *date);
378 }
379 }
380
381 #[test]
382 fn it_returns_midnight_for_normal_dates() {
383 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
384 let result = beginning_of_day(date);
385
386 assert_eq!(result.date_naive(), date);
387 }
388 }
389
390 mod chronify {
391 use pretty_assertions::assert_eq;
392
393 use super::*;
394
395 #[test]
396 fn it_parses_absolute_iso_date() {
397 let result = chronify("2024-03-15").unwrap();
398
399 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
400 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
401 }
402
403 #[test]
404 fn it_parses_absolute_iso_datetime() {
405 let result = chronify("2024-03-15 14:30").unwrap();
406
407 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
408 assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
409 }
410
411 #[test]
412 fn it_parses_absolute_us_long_date() {
413 let result = chronify("03/15/2024").unwrap();
414
415 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
416 }
417
418 #[test]
419 fn it_parses_absolute_us_short_date() {
420 let result = chronify("03/15/24").unwrap();
421
422 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
423 }
424
425 #[test]
426 fn it_parses_bare_abbreviated_day_name() {
427 let result = chronify("fri").unwrap();
428
429 assert_eq!(result.weekday(), Weekday::Fri);
430 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
431 }
432
433 #[test]
434 fn it_parses_bare_full_day_name() {
435 let result = chronify("friday").unwrap();
436
437 assert_eq!(result.weekday(), Weekday::Fri);
438 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
439 }
440
441 #[test]
442 fn it_parses_combined_day_of_week_with_time() {
443 let result = chronify("yesterday 3pm").unwrap();
444 let expected_date = (Local::now() - Duration::days(1)).date_naive();
445
446 assert_eq!(result.date_naive(), expected_date);
447 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
448 }
449
450 #[test]
451 fn it_parses_combined_with_24h_time() {
452 let result = chronify("tomorrow 15:00").unwrap();
453 let expected_date = (Local::now() + Duration::days(1)).date_naive();
454
455 assert_eq!(result.date_naive(), expected_date);
456 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
457 }
458
459 #[test]
460 fn it_parses_combined_with_at_keyword() {
461 let result = chronify("yesterday at noon").unwrap();
462 let expected_date = (Local::now() - Duration::days(1)).date_naive();
463
464 assert_eq!(result.date_naive(), expected_date);
465 assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
466 }
467
468 #[test]
469 fn it_parses_now() {
470 let before = Local::now();
471 let result = chronify("now").unwrap();
472 let after = Local::now();
473
474 assert!(result >= before && result <= after);
475 }
476
477 #[test]
478 fn it_parses_shorthand_duration_hours() {
479 let before = Local::now();
480 let result = chronify("24h").unwrap();
481 let after = Local::now();
482
483 let expected_before = before - Duration::hours(24);
484 let expected_after = after - Duration::hours(24);
485
486 assert!(result >= expected_before && result <= expected_after);
487 }
488
489 #[test]
490 fn it_parses_shorthand_duration_minutes() {
491 let before = Local::now();
492 let result = chronify("30m").unwrap();
493 let after = Local::now();
494
495 let expected_before = before - Duration::minutes(30);
496 let expected_after = after - Duration::minutes(30);
497
498 assert!(result >= expected_before && result <= expected_after);
499 }
500
501 #[test]
502 fn it_parses_shorthand_duration_multi_unit() {
503 let before = Local::now();
504 let result = chronify("1d2h").unwrap();
505 let after = Local::now();
506
507 let expected_before = before - Duration::hours(26);
508 let expected_after = after - Duration::hours(26);
509
510 assert!(result >= expected_before && result <= expected_after);
511 }
512
513 #[test]
514 fn it_parses_today() {
515 let result = chronify("today").unwrap();
516
517 assert_eq!(result.date_naive(), Local::now().date_naive());
518 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
519 }
520
521 #[test]
522 fn it_parses_tomorrow() {
523 let result = chronify("tomorrow").unwrap();
524 let expected = (Local::now() + Duration::days(1)).date_naive();
525
526 assert_eq!(result.date_naive(), expected);
527 }
528
529 #[test]
530 fn it_parses_yesterday() {
531 let result = chronify("yesterday").unwrap();
532 let expected = (Local::now() - Duration::days(1)).date_naive();
533
534 assert_eq!(result.date_naive(), expected);
535 }
536
537 #[test]
538 fn it_rejects_empty_input() {
539 let err = chronify("").unwrap_err();
540
541 assert!(matches!(err, Error::InvalidTimeExpression(_)));
542 }
543
544 #[test]
545 fn it_rejects_invalid_input() {
546 let err = chronify("not a date").unwrap_err();
547
548 assert!(matches!(err, Error::InvalidTimeExpression(_)));
549 }
550
551 #[test]
552 fn it_trims_whitespace() {
553 let result = chronify(" today ").unwrap();
554
555 assert_eq!(result.date_naive(), Local::now().date_naive());
556 }
557 }
558
559 mod parse_ago {
560 use pretty_assertions::assert_eq;
561
562 use super::*;
563
564 #[test]
565 fn it_parses_days_ago() {
566 let now = Local::now();
567 let result = parse_ago("3 days ago", now).unwrap();
568
569 assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
570 }
571
572 #[test]
573 fn it_parses_hours_ago() {
574 let now = Local::now();
575 let result = parse_ago("2 hours ago", now).unwrap();
576 let expected = now - Duration::hours(2);
577
578 assert!((result - expected).num_seconds().abs() < 1);
579 }
580
581 #[test]
582 fn it_parses_minutes_shorthand() {
583 let now = Local::now();
584 let result = parse_ago("30m ago", now).unwrap();
585 let expected = now - Duration::minutes(30);
586
587 assert!((result - expected).num_seconds().abs() < 1);
588 }
589
590 #[test]
591 fn it_parses_weeks_ago() {
592 let now = Local::now();
593 let result = parse_ago("2 weeks ago", now).unwrap();
594
595 assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
596 }
597
598 #[test]
599 fn it_parses_written_numbers() {
600 let now = Local::now();
601 let result = parse_ago("one hour ago", now).unwrap();
602 let expected = now - Duration::hours(1);
603
604 assert!((result - expected).num_seconds().abs() < 1);
605 }
606
607 #[test]
608 fn it_returns_none_for_invalid_input() {
609 let now = Local::now();
610
611 assert!(parse_ago("not valid", now).is_none());
612 }
613 }
614
615 mod parse_day_of_week {
616 use pretty_assertions::assert_eq;
617
618 use super::*;
619
620 #[test]
621 fn it_parses_abbreviations() {
622 for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
623 let result = parse_day_of_week(abbr);
624 assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
625 }
626 }
627
628 #[test]
629 fn it_parses_alternate_abbreviations() {
630 for abbr in &["tues", "weds", "thur", "thurs"] {
631 let result = parse_day_of_week(abbr);
632 assert!(
633 result.is_some(),
634 "parse_day_of_week should parse alternate abbreviation: {abbr}"
635 );
636 }
637 }
638
639 #[test]
640 fn it_parses_full_day_names() {
641 for name in &[
642 "monday",
643 "tuesday",
644 "wednesday",
645 "thursday",
646 "friday",
647 "saturday",
648 "sunday",
649 ] {
650 let result = parse_day_of_week(name);
651 assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
652 }
653 }
654
655 #[test]
656 fn it_parses_full_names_with_direction() {
657 let result = parse_day_of_week("last friday");
658 assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
659
660 let result = parse_day_of_week("next monday");
661 assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
662 }
663
664 #[test]
665 fn it_resolves_bare_day_to_most_recent_past() {
666 let result = parse_day_of_week("friday").unwrap();
667 let now = Local::now();
668
669 assert!(result <= now, "bare day name should resolve to a past date");
671
672 let cutoff = now - Duration::days(8);
675 assert!(
676 result > cutoff,
677 "bare day name should resolve to within the last 7 days"
678 );
679
680 assert_eq!(result.weekday(), Weekday::Fri);
682 }
683 }
684
685 mod parse_number {
686 use pretty_assertions::assert_eq;
687
688 use super::*;
689
690 #[test]
691 fn it_parses_a_as_one() {
692 assert_eq!(parse_number("a"), Some(1));
693 assert_eq!(parse_number("an"), Some(1));
694 }
695
696 #[test]
697 fn it_parses_digits() {
698 assert_eq!(parse_number("42"), Some(42));
699 }
700
701 #[test]
702 fn it_parses_written_numbers() {
703 assert_eq!(parse_number("one"), Some(1));
704 assert_eq!(parse_number("six"), Some(6));
705 assert_eq!(parse_number("twelve"), Some(12));
706 }
707
708 #[test]
709 fn it_returns_none_for_invalid_input() {
710 assert!(parse_number("foo").is_none());
711 }
712 }
713
714 mod parse_shorthand_duration {
715 use super::*;
716
717 #[test]
718 fn it_parses_hours() {
719 let before = Local::now();
720 let result = parse_shorthand_duration("48h").unwrap();
721 let after = Local::now();
722
723 let expected_before = before - Duration::hours(48);
724 let expected_after = after - Duration::hours(48);
725
726 assert!(result >= expected_before && result <= expected_after);
727 }
728
729 #[test]
730 fn it_parses_minutes() {
731 let before = Local::now();
732 let result = parse_shorthand_duration("15m").unwrap();
733 let after = Local::now();
734
735 let expected_before = before - Duration::minutes(15);
736 let expected_after = after - Duration::minutes(15);
737
738 assert!(result >= expected_before && result <= expected_after);
739 }
740
741 #[test]
742 fn it_returns_none_for_invalid_input() {
743 assert!(parse_shorthand_duration("not valid").is_none());
744 }
745 }
746
747 mod parse_time_only {
748 use pretty_assertions::assert_eq;
749
750 use super::*;
751
752 #[test]
753 fn it_resolves_bare_time_to_today() {
754 let result = parse_time_only("3pm").unwrap();
755
756 assert_eq!(result.date_naive(), Local::now().date_naive());
757 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
758 }
759
760 #[test]
761 fn it_resolves_future_time_to_today() {
762 let result = parse_time_only("11:59pm").unwrap();
763
764 assert_eq!(result.date_naive(), Local::now().date_naive());
765 assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
766 }
767 }
768
769 mod parse_weekday {
770 use pretty_assertions::assert_eq;
771
772 use super::*;
773
774 #[test]
775 fn it_parses_abbreviations() {
776 assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
777 assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
778 assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
779 assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
780 assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
781 assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
782 assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
783 }
784
785 #[test]
786 fn it_returns_none_for_invalid_input() {
787 assert!(parse_weekday("xyz").is_none());
788 }
789 }
790
791 mod resolve_time_expression {
792 use pretty_assertions::assert_eq;
793
794 use super::*;
795
796 #[test]
797 fn it_parses_12_hour_with_minutes() {
798 let result = resolve_time_expression("3:30pm").unwrap();
799
800 assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
801 }
802
803 #[test]
804 fn it_parses_12_hour_without_minutes() {
805 let result = resolve_time_expression("3pm").unwrap();
806
807 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
808 }
809
810 #[test]
811 fn it_parses_12am_as_midnight() {
812 let result = resolve_time_expression("12am").unwrap();
813
814 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
815 }
816
817 #[test]
818 fn it_parses_12pm_as_noon() {
819 let result = resolve_time_expression("12pm").unwrap();
820
821 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
822 }
823
824 #[test]
825 fn it_parses_24_hour() {
826 let result = resolve_time_expression("15:00").unwrap();
827
828 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
829 }
830
831 #[test]
832 fn it_parses_midnight() {
833 let result = resolve_time_expression("midnight").unwrap();
834
835 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
836 }
837
838 #[test]
839 fn it_parses_noon() {
840 let result = resolve_time_expression("noon").unwrap();
841
842 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
843 }
844
845 #[test]
846 fn it_rejects_invalid_hour() {
847 assert!(resolve_time_expression("25:00").is_none());
848 }
849
850 #[test]
851 fn it_returns_none_for_invalid_input() {
852 assert!(resolve_time_expression("not a time").is_none());
853 }
854 }
855
856 mod resolve_weekday {
857 use pretty_assertions::assert_eq;
858
859 use super::*;
860
861 #[test]
862 fn it_defaults_bare_weekday_to_past() {
863 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, None);
865
866 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
867 }
868
869 #[test]
870 fn it_resolves_last_to_past() {
871 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("last"));
873
874 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
875 }
876
877 #[test]
878 fn it_resolves_next_to_future() {
879 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("next"));
881
882 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
883 }
884
885 #[test]
886 fn it_resolves_same_day_last_to_one_week_ago() {
887 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("last"));
889
890 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
891 }
892
893 #[test]
894 fn it_resolves_same_day_next_to_one_week_ahead() {
895 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("next"));
897
898 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
899 }
900
901 #[test]
902 fn it_resolves_this_same_day_to_today() {
903 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("this"));
905
906 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
907 }
908
909 #[test]
910 fn it_resolves_this_past_day_to_current_week() {
911 let now = Local.with_ymd_and_hms(2026, 3, 19, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("this"));
913
914 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
916 }
917
918 #[test]
919 fn it_resolves_this_future_day_to_current_week() {
920 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("this"));
922
923 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
925 }
926
927 #[test]
928 fn it_resolves_bare_same_day_to_one_week_ago() {
929 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, None);
931
932 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
934 }
935 }
936}