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).expect("valid hour 1..=12"))
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 {
196 parse_absolute(date_part)?
197 };
198
199 apply_time_to_date(base_date, time)
200}
201
202fn parse_day_of_week(input: &str) -> Option<DateTime<Local>> {
205 let now = Local::now();
206 let caps = RE_DAY_OF_WEEK.captures(input)?;
207
208 let direction = caps.get(1).map(|m| m.as_str());
209 let weekday = parse_weekday(&caps[2])?;
210
211 Some(beginning_of_day(resolve_weekday(now, weekday, direction).date_naive()))
212}
213
214fn parse_number(s: &str) -> Option<i64> {
217 match s {
218 "one" | "a" | "an" => Some(1),
219 "two" => Some(2),
220 "three" => Some(3),
221 "four" => Some(4),
222 "five" => Some(5),
223 "six" => Some(6),
224 "seven" => Some(7),
225 "eight" => Some(8),
226 "nine" => Some(9),
227 "ten" => Some(10),
228 "eleven" => Some(11),
229 "twelve" => Some(12),
230 _ => s.parse().ok(),
231 }
232}
233
234fn parse_relative(input: &str) -> Option<DateTime<Local>> {
237 let now = Local::now();
238
239 match input {
240 "now" => return Some(now),
241 "today" => return Some(beginning_of_day(now.date_naive())),
242 "yesterday" => return Some(beginning_of_day((now - Duration::days(1)).date_naive())),
243 "tomorrow" => return Some(beginning_of_day((now + Duration::days(1)).date_naive())),
244 _ => {}
245 }
246
247 parse_ago(input, now)
248}
249
250fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
253 let duration = parse_duration(input).ok()?;
254 Some(Local::now() - duration)
255}
256
257fn parse_time_only(input: &str) -> Option<DateTime<Local>> {
261 let time = resolve_time_expression(input)?;
262 let now = Local::now();
263 apply_time_to_date(now, time)
264}
265
266fn parse_weekday(s: &str) -> Option<Weekday> {
268 match s {
269 s if s.starts_with("mon") => Some(Weekday::Mon),
270 s if s.starts_with("tue") => Some(Weekday::Tue),
271 s if s.starts_with("wed") => Some(Weekday::Wed),
272 s if s.starts_with("thu") => Some(Weekday::Thu),
273 s if s.starts_with("fri") => Some(Weekday::Fri),
274 s if s.starts_with("sat") => Some(Weekday::Sat),
275 s if s.starts_with("sun") => Some(Weekday::Sun),
276 _ => None,
277 }
278}
279
280fn resolve_time_expression(input: &str) -> Option<NaiveTime> {
283 match input {
284 "noon" => return NaiveTime::from_hms_opt(12, 0, 0),
285 "midnight" => return NaiveTime::from_hms_opt(0, 0, 0),
286 _ => {}
287 }
288
289 if let Some(caps) = RE_TIME_12H.captures(input) {
291 let mut hour: u32 = caps[1].parse().ok()?;
292 let min: u32 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
293 let period = &caps[3];
294
295 if hour > 12 || min > 59 {
296 return None;
297 }
298
299 if period == "am" && hour == 12 {
300 hour = 0;
301 } else if period == "pm" && hour != 12 {
302 hour += 12;
303 }
304
305 return NaiveTime::from_hms_opt(hour, min, 0);
306 }
307
308 if let Some(caps) = RE_TIME_24H.captures(input) {
310 let hour: u32 = caps[1].parse().ok()?;
311 let min: u32 = caps[2].parse().ok()?;
312
313 if hour > 23 || min > 59 {
314 return None;
315 }
316
317 return NaiveTime::from_hms_opt(hour, min, 0);
318 }
319
320 None
321}
322
323fn resolve_weekday(now: DateTime<Local>, target: Weekday, direction: Option<&str>) -> DateTime<Local> {
326 let current = now.weekday();
327 let current_num = current.num_days_from_monday() as i64;
328 let target_num = target.num_days_from_monday() as i64;
329
330 match direction {
331 Some("next") => {
332 let d = target_num - current_num;
333 let diff = if d <= 0 { d + 7 } else { d };
334 now + Duration::days(diff)
335 }
336 Some("this") => {
337 let d = target_num - current_num;
340 if d >= 0 {
341 now + Duration::days(d)
342 } else {
343 now - Duration::days(-d)
344 }
345 }
346 _ => {
347 let d = current_num - target_num;
350 let diff = if d <= 0 { d + 7 } else { d };
351 now - Duration::days(diff)
352 }
353 }
354}
355
356#[cfg(test)]
357mod test {
358 use super::*;
359
360 mod beginning_of_day {
361 use super::*;
362
363 #[test]
364 fn it_does_not_panic_on_dst_gap_dates() {
365 let dates = [
369 NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
370 NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
371 NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
372 ];
373 for date in &dates {
374 let result = beginning_of_day(*date);
375 assert_eq!(result.date_naive(), *date);
376 }
377 }
378
379 #[test]
380 fn it_returns_midnight_for_normal_dates() {
381 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
382 let result = beginning_of_day(date);
383
384 assert_eq!(result.date_naive(), date);
385 }
386 }
387
388 mod chronify {
389 use pretty_assertions::assert_eq;
390
391 use super::*;
392
393 #[test]
394 fn it_parses_absolute_iso_date() {
395 let result = chronify("2024-03-15").unwrap();
396
397 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
398 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
399 }
400
401 #[test]
402 fn it_parses_absolute_iso_datetime() {
403 let result = chronify("2024-03-15 14:30").unwrap();
404
405 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
406 assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
407 }
408
409 #[test]
410 fn it_parses_absolute_us_long_date() {
411 let result = chronify("03/15/2024").unwrap();
412
413 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
414 }
415
416 #[test]
417 fn it_parses_absolute_us_short_date() {
418 let result = chronify("03/15/24").unwrap();
419
420 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
421 }
422
423 #[test]
424 fn it_parses_bare_abbreviated_day_name() {
425 let result = chronify("fri").unwrap();
426
427 assert_eq!(result.weekday(), Weekday::Fri);
428 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
429 }
430
431 #[test]
432 fn it_parses_bare_full_day_name() {
433 let result = chronify("friday").unwrap();
434
435 assert_eq!(result.weekday(), Weekday::Fri);
436 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
437 }
438
439 #[test]
440 fn it_parses_combined_day_of_week_with_time() {
441 let result = chronify("yesterday 3pm").unwrap();
442 let expected_date = (Local::now() - Duration::days(1)).date_naive();
443
444 assert_eq!(result.date_naive(), expected_date);
445 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
446 }
447
448 #[test]
449 fn it_parses_combined_with_24h_time() {
450 let result = chronify("tomorrow 15:00").unwrap();
451 let expected_date = (Local::now() + Duration::days(1)).date_naive();
452
453 assert_eq!(result.date_naive(), expected_date);
454 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
455 }
456
457 #[test]
458 fn it_parses_combined_with_at_keyword() {
459 let result = chronify("yesterday at noon").unwrap();
460 let expected_date = (Local::now() - Duration::days(1)).date_naive();
461
462 assert_eq!(result.date_naive(), expected_date);
463 assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
464 }
465
466 #[test]
467 fn it_parses_now() {
468 let before = Local::now();
469 let result = chronify("now").unwrap();
470 let after = Local::now();
471
472 assert!(result >= before && result <= after);
473 }
474
475 #[test]
476 fn it_parses_shorthand_duration_hours() {
477 let before = Local::now();
478 let result = chronify("24h").unwrap();
479 let after = Local::now();
480
481 let expected_before = before - Duration::hours(24);
482 let expected_after = after - Duration::hours(24);
483
484 assert!(result >= expected_before && result <= expected_after);
485 }
486
487 #[test]
488 fn it_parses_shorthand_duration_minutes() {
489 let before = Local::now();
490 let result = chronify("30m").unwrap();
491 let after = Local::now();
492
493 let expected_before = before - Duration::minutes(30);
494 let expected_after = after - Duration::minutes(30);
495
496 assert!(result >= expected_before && result <= expected_after);
497 }
498
499 #[test]
500 fn it_parses_shorthand_duration_multi_unit() {
501 let before = Local::now();
502 let result = chronify("1d2h").unwrap();
503 let after = Local::now();
504
505 let expected_before = before - Duration::hours(26);
506 let expected_after = after - Duration::hours(26);
507
508 assert!(result >= expected_before && result <= expected_after);
509 }
510
511 #[test]
512 fn it_parses_today() {
513 let result = chronify("today").unwrap();
514
515 assert_eq!(result.date_naive(), Local::now().date_naive());
516 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
517 }
518
519 #[test]
520 fn it_parses_tomorrow() {
521 let result = chronify("tomorrow").unwrap();
522 let expected = (Local::now() + Duration::days(1)).date_naive();
523
524 assert_eq!(result.date_naive(), expected);
525 }
526
527 #[test]
528 fn it_parses_yesterday() {
529 let result = chronify("yesterday").unwrap();
530 let expected = (Local::now() - Duration::days(1)).date_naive();
531
532 assert_eq!(result.date_naive(), expected);
533 }
534
535 #[test]
536 fn it_rejects_empty_input() {
537 let err = chronify("").unwrap_err();
538
539 assert!(matches!(err, Error::InvalidTimeExpression(_)));
540 }
541
542 #[test]
543 fn it_rejects_invalid_input() {
544 let err = chronify("not a date").unwrap_err();
545
546 assert!(matches!(err, Error::InvalidTimeExpression(_)));
547 }
548
549 #[test]
550 fn it_trims_whitespace() {
551 let result = chronify(" today ").unwrap();
552
553 assert_eq!(result.date_naive(), Local::now().date_naive());
554 }
555 }
556
557 mod parse_ago {
558 use pretty_assertions::assert_eq;
559
560 use super::*;
561
562 #[test]
563 fn it_parses_days_ago() {
564 let now = Local::now();
565 let result = parse_ago("3 days ago", now).unwrap();
566
567 assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
568 }
569
570 #[test]
571 fn it_parses_hours_ago() {
572 let now = Local::now();
573 let result = parse_ago("2 hours ago", now).unwrap();
574 let expected = now - Duration::hours(2);
575
576 assert!((result - expected).num_seconds().abs() < 1);
577 }
578
579 #[test]
580 fn it_parses_minutes_shorthand() {
581 let now = Local::now();
582 let result = parse_ago("30m ago", now).unwrap();
583 let expected = now - Duration::minutes(30);
584
585 assert!((result - expected).num_seconds().abs() < 1);
586 }
587
588 #[test]
589 fn it_parses_weeks_ago() {
590 let now = Local::now();
591 let result = parse_ago("2 weeks ago", now).unwrap();
592
593 assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
594 }
595
596 #[test]
597 fn it_parses_written_numbers() {
598 let now = Local::now();
599 let result = parse_ago("one hour ago", now).unwrap();
600 let expected = now - Duration::hours(1);
601
602 assert!((result - expected).num_seconds().abs() < 1);
603 }
604
605 #[test]
606 fn it_returns_none_for_invalid_input() {
607 let now = Local::now();
608
609 assert!(parse_ago("not valid", now).is_none());
610 }
611 }
612
613 mod parse_day_of_week {
614 use pretty_assertions::assert_eq;
615
616 use super::*;
617
618 #[test]
619 fn it_parses_abbreviations() {
620 for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
621 let result = parse_day_of_week(abbr);
622 assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
623 }
624 }
625
626 #[test]
627 fn it_parses_alternate_abbreviations() {
628 for abbr in &["tues", "weds", "thur", "thurs"] {
629 let result = parse_day_of_week(abbr);
630 assert!(
631 result.is_some(),
632 "parse_day_of_week should parse alternate abbreviation: {abbr}"
633 );
634 }
635 }
636
637 #[test]
638 fn it_parses_full_day_names() {
639 for name in &[
640 "monday",
641 "tuesday",
642 "wednesday",
643 "thursday",
644 "friday",
645 "saturday",
646 "sunday",
647 ] {
648 let result = parse_day_of_week(name);
649 assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
650 }
651 }
652
653 #[test]
654 fn it_parses_full_names_with_direction() {
655 let result = parse_day_of_week("last friday");
656 assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
657
658 let result = parse_day_of_week("next monday");
659 assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
660 }
661
662 #[test]
663 fn it_resolves_bare_day_to_most_recent_past() {
664 let result = parse_day_of_week("friday").unwrap();
665 let now = Local::now();
666
667 assert!(result <= now, "bare day name should resolve to a past date");
669
670 let cutoff = now - Duration::days(8);
673 assert!(
674 result > cutoff,
675 "bare day name should resolve to within the last 7 days"
676 );
677
678 assert_eq!(result.weekday(), Weekday::Fri);
680 }
681 }
682
683 mod parse_number {
684 use pretty_assertions::assert_eq;
685
686 use super::*;
687
688 #[test]
689 fn it_parses_a_as_one() {
690 assert_eq!(parse_number("a"), Some(1));
691 assert_eq!(parse_number("an"), Some(1));
692 }
693
694 #[test]
695 fn it_parses_digits() {
696 assert_eq!(parse_number("42"), Some(42));
697 }
698
699 #[test]
700 fn it_parses_written_numbers() {
701 assert_eq!(parse_number("one"), Some(1));
702 assert_eq!(parse_number("six"), Some(6));
703 assert_eq!(parse_number("twelve"), Some(12));
704 }
705
706 #[test]
707 fn it_returns_none_for_invalid_input() {
708 assert!(parse_number("foo").is_none());
709 }
710 }
711
712 mod parse_shorthand_duration {
713 use super::*;
714
715 #[test]
716 fn it_parses_hours() {
717 let before = Local::now();
718 let result = parse_shorthand_duration("48h").unwrap();
719 let after = Local::now();
720
721 let expected_before = before - Duration::hours(48);
722 let expected_after = after - Duration::hours(48);
723
724 assert!(result >= expected_before && result <= expected_after);
725 }
726
727 #[test]
728 fn it_parses_minutes() {
729 let before = Local::now();
730 let result = parse_shorthand_duration("15m").unwrap();
731 let after = Local::now();
732
733 let expected_before = before - Duration::minutes(15);
734 let expected_after = after - Duration::minutes(15);
735
736 assert!(result >= expected_before && result <= expected_after);
737 }
738
739 #[test]
740 fn it_returns_none_for_invalid_input() {
741 assert!(parse_shorthand_duration("not valid").is_none());
742 }
743 }
744
745 mod parse_time_only {
746 use pretty_assertions::assert_eq;
747
748 use super::*;
749
750 #[test]
751 fn it_resolves_bare_time_to_today() {
752 let result = parse_time_only("3pm").unwrap();
753
754 assert_eq!(result.date_naive(), Local::now().date_naive());
755 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
756 }
757
758 #[test]
759 fn it_resolves_future_time_to_today() {
760 let result = parse_time_only("11:59pm").unwrap();
761
762 assert_eq!(result.date_naive(), Local::now().date_naive());
763 assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
764 }
765 }
766
767 mod parse_weekday {
768 use pretty_assertions::assert_eq;
769
770 use super::*;
771
772 #[test]
773 fn it_parses_abbreviations() {
774 assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
775 assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
776 assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
777 assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
778 assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
779 assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
780 assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
781 }
782
783 #[test]
784 fn it_returns_none_for_invalid_input() {
785 assert!(parse_weekday("xyz").is_none());
786 }
787 }
788
789 mod resolve_time_expression {
790 use pretty_assertions::assert_eq;
791
792 use super::*;
793
794 #[test]
795 fn it_parses_12_hour_with_minutes() {
796 let result = resolve_time_expression("3:30pm").unwrap();
797
798 assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
799 }
800
801 #[test]
802 fn it_parses_12_hour_without_minutes() {
803 let result = resolve_time_expression("3pm").unwrap();
804
805 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
806 }
807
808 #[test]
809 fn it_parses_12am_as_midnight() {
810 let result = resolve_time_expression("12am").unwrap();
811
812 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
813 }
814
815 #[test]
816 fn it_parses_12pm_as_noon() {
817 let result = resolve_time_expression("12pm").unwrap();
818
819 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
820 }
821
822 #[test]
823 fn it_parses_24_hour() {
824 let result = resolve_time_expression("15:00").unwrap();
825
826 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
827 }
828
829 #[test]
830 fn it_parses_midnight() {
831 let result = resolve_time_expression("midnight").unwrap();
832
833 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
834 }
835
836 #[test]
837 fn it_parses_noon() {
838 let result = resolve_time_expression("noon").unwrap();
839
840 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
841 }
842
843 #[test]
844 fn it_rejects_invalid_hour() {
845 assert!(resolve_time_expression("25:00").is_none());
846 }
847
848 #[test]
849 fn it_returns_none_for_invalid_input() {
850 assert!(resolve_time_expression("not a time").is_none());
851 }
852 }
853
854 mod resolve_weekday {
855 use pretty_assertions::assert_eq;
856
857 use super::*;
858
859 #[test]
860 fn it_defaults_bare_weekday_to_past() {
861 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, None);
863
864 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
865 }
866
867 #[test]
868 fn it_resolves_last_to_past() {
869 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("last"));
871
872 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
873 }
874
875 #[test]
876 fn it_resolves_next_to_future() {
877 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("next"));
879
880 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
881 }
882
883 #[test]
884 fn it_resolves_same_day_last_to_one_week_ago() {
885 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("last"));
887
888 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
889 }
890
891 #[test]
892 fn it_resolves_same_day_next_to_one_week_ahead() {
893 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("next"));
895
896 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
897 }
898
899 #[test]
900 fn it_resolves_this_same_day_to_today() {
901 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("this"));
903
904 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
905 }
906
907 #[test]
908 fn it_resolves_this_past_day_to_current_week() {
909 let now = Local.with_ymd_and_hms(2026, 3, 19, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("this"));
911
912 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
914 }
915
916 #[test]
917 fn it_resolves_this_future_day_to_current_week() {
918 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("this"));
920
921 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
923 }
924
925 #[test]
926 fn it_resolves_bare_same_day_to_one_week_ago() {
927 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, None);
929
930 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
932 }
933 }
934}