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