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 let diff = match direction {
333 Some("next") => {
334 let d = target_num - current_num;
335 if d <= 0 { d + 7 } else { d }
336 }
337 Some("last") => {
338 let d = current_num - target_num;
339 if d <= 0 { d + 7 } else { d }
340 }
341 _ => {
342 let d = current_num - target_num;
344 if d <= 0 { d + 7 } else { d }
345 }
346 };
347
348 match direction {
349 Some("next") => now + Duration::days(diff),
350 _ => now - Duration::days(diff),
351 }
352}
353
354#[cfg(test)]
355mod test {
356 use super::*;
357
358 mod chronify {
359 use pretty_assertions::assert_eq;
360
361 use super::*;
362
363 #[test]
364 fn it_parses_absolute_iso_date() {
365 let result = chronify("2024-03-15").unwrap();
366
367 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
368 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
369 }
370
371 #[test]
372 fn it_parses_absolute_iso_datetime() {
373 let result = chronify("2024-03-15 14:30").unwrap();
374
375 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
376 assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
377 }
378
379 #[test]
380 fn it_parses_absolute_us_long_date() {
381 let result = chronify("03/15/2024").unwrap();
382
383 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
384 }
385
386 #[test]
387 fn it_parses_absolute_us_short_date() {
388 let result = chronify("03/15/24").unwrap();
389
390 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
391 }
392
393 #[test]
394 fn it_parses_bare_abbreviated_day_name() {
395 let result = chronify("fri").unwrap();
396
397 assert_eq!(result.weekday(), Weekday::Fri);
398 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
399 }
400
401 #[test]
402 fn it_parses_bare_full_day_name() {
403 let result = chronify("friday").unwrap();
404
405 assert_eq!(result.weekday(), Weekday::Fri);
406 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
407 }
408
409 #[test]
410 fn it_parses_combined_day_of_week_with_time() {
411 let result = chronify("yesterday 3pm").unwrap();
412 let expected_date = (Local::now() - Duration::days(1)).date_naive();
413
414 assert_eq!(result.date_naive(), expected_date);
415 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
416 }
417
418 #[test]
419 fn it_parses_combined_with_24h_time() {
420 let result = chronify("tomorrow 15:00").unwrap();
421 let expected_date = (Local::now() + Duration::days(1)).date_naive();
422
423 assert_eq!(result.date_naive(), expected_date);
424 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
425 }
426
427 #[test]
428 fn it_parses_combined_with_at_keyword() {
429 let result = chronify("yesterday at noon").unwrap();
430 let expected_date = (Local::now() - Duration::days(1)).date_naive();
431
432 assert_eq!(result.date_naive(), expected_date);
433 assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
434 }
435
436 #[test]
437 fn it_parses_now() {
438 let before = Local::now();
439 let result = chronify("now").unwrap();
440 let after = Local::now();
441
442 assert!(result >= before && result <= after);
443 }
444
445 #[test]
446 fn it_parses_shorthand_duration_hours() {
447 let before = Local::now();
448 let result = chronify("24h").unwrap();
449 let after = Local::now();
450
451 let expected_before = before - Duration::hours(24);
452 let expected_after = after - Duration::hours(24);
453
454 assert!(result >= expected_before && result <= expected_after);
455 }
456
457 #[test]
458 fn it_parses_shorthand_duration_minutes() {
459 let before = Local::now();
460 let result = chronify("30m").unwrap();
461 let after = Local::now();
462
463 let expected_before = before - Duration::minutes(30);
464 let expected_after = after - Duration::minutes(30);
465
466 assert!(result >= expected_before && result <= expected_after);
467 }
468
469 #[test]
470 fn it_parses_shorthand_duration_multi_unit() {
471 let before = Local::now();
472 let result = chronify("1d2h").unwrap();
473 let after = Local::now();
474
475 let expected_before = before - Duration::hours(26);
476 let expected_after = after - Duration::hours(26);
477
478 assert!(result >= expected_before && result <= expected_after);
479 }
480
481 #[test]
482 fn it_parses_today() {
483 let result = chronify("today").unwrap();
484
485 assert_eq!(result.date_naive(), Local::now().date_naive());
486 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
487 }
488
489 #[test]
490 fn it_parses_tomorrow() {
491 let result = chronify("tomorrow").unwrap();
492 let expected = (Local::now() + Duration::days(1)).date_naive();
493
494 assert_eq!(result.date_naive(), expected);
495 }
496
497 #[test]
498 fn it_parses_yesterday() {
499 let result = chronify("yesterday").unwrap();
500 let expected = (Local::now() - Duration::days(1)).date_naive();
501
502 assert_eq!(result.date_naive(), expected);
503 }
504
505 #[test]
506 fn it_rejects_empty_input() {
507 let err = chronify("").unwrap_err();
508
509 assert!(matches!(err, Error::InvalidTimeExpression(_)));
510 }
511
512 #[test]
513 fn it_rejects_invalid_input() {
514 let err = chronify("not a date").unwrap_err();
515
516 assert!(matches!(err, Error::InvalidTimeExpression(_)));
517 }
518
519 #[test]
520 fn it_trims_whitespace() {
521 let result = chronify(" today ").unwrap();
522
523 assert_eq!(result.date_naive(), Local::now().date_naive());
524 }
525 }
526
527 mod parse_ago {
528 use pretty_assertions::assert_eq;
529
530 use super::*;
531
532 #[test]
533 fn it_parses_days_ago() {
534 let now = Local::now();
535 let result = parse_ago("3 days ago", now).unwrap();
536
537 assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
538 }
539
540 #[test]
541 fn it_parses_hours_ago() {
542 let now = Local::now();
543 let result = parse_ago("2 hours ago", now).unwrap();
544 let expected = now - Duration::hours(2);
545
546 assert!((result - expected).num_seconds().abs() < 1);
547 }
548
549 #[test]
550 fn it_parses_minutes_shorthand() {
551 let now = Local::now();
552 let result = parse_ago("30m ago", now).unwrap();
553 let expected = now - Duration::minutes(30);
554
555 assert!((result - expected).num_seconds().abs() < 1);
556 }
557
558 #[test]
559 fn it_parses_weeks_ago() {
560 let now = Local::now();
561 let result = parse_ago("2 weeks ago", now).unwrap();
562
563 assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
564 }
565
566 #[test]
567 fn it_parses_written_numbers() {
568 let now = Local::now();
569 let result = parse_ago("one hour ago", now).unwrap();
570 let expected = now - Duration::hours(1);
571
572 assert!((result - expected).num_seconds().abs() < 1);
573 }
574
575 #[test]
576 fn it_returns_none_for_invalid_input() {
577 let now = Local::now();
578
579 assert!(parse_ago("not valid", now).is_none());
580 }
581 }
582
583 mod parse_day_of_week {
584 use pretty_assertions::assert_eq;
585
586 use super::*;
587
588 #[test]
589 fn it_parses_full_day_names() {
590 for name in &[
591 "monday",
592 "tuesday",
593 "wednesday",
594 "thursday",
595 "friday",
596 "saturday",
597 "sunday",
598 ] {
599 let result = parse_day_of_week(name);
600 assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
601 }
602 }
603
604 #[test]
605 fn it_parses_abbreviations() {
606 for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
607 let result = parse_day_of_week(abbr);
608 assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
609 }
610 }
611
612 #[test]
613 fn it_parses_alternate_abbreviations() {
614 for abbr in &["tues", "weds", "thur", "thurs"] {
615 let result = parse_day_of_week(abbr);
616 assert!(
617 result.is_some(),
618 "parse_day_of_week should parse alternate abbreviation: {abbr}"
619 );
620 }
621 }
622
623 #[test]
624 fn it_parses_full_names_with_direction() {
625 let result = parse_day_of_week("last friday");
626 assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
627
628 let result = parse_day_of_week("next monday");
629 assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
630 }
631
632 #[test]
633 fn it_resolves_bare_day_to_most_recent_past() {
634 let result = parse_day_of_week("friday").unwrap();
635 let now = Local::now();
636
637 assert!(result <= now, "bare day name should resolve to a past date");
639
640 let cutoff = now - Duration::days(8);
643 assert!(
644 result > cutoff,
645 "bare day name should resolve to within the last 7 days"
646 );
647
648 assert_eq!(result.weekday(), Weekday::Fri);
650 }
651 }
652
653 mod parse_number {
654 use pretty_assertions::assert_eq;
655
656 use super::*;
657
658 #[test]
659 fn it_parses_a_as_one() {
660 assert_eq!(parse_number("a"), Some(1));
661 assert_eq!(parse_number("an"), Some(1));
662 }
663
664 #[test]
665 fn it_parses_digits() {
666 assert_eq!(parse_number("42"), Some(42));
667 }
668
669 #[test]
670 fn it_parses_written_numbers() {
671 assert_eq!(parse_number("one"), Some(1));
672 assert_eq!(parse_number("six"), Some(6));
673 assert_eq!(parse_number("twelve"), Some(12));
674 }
675
676 #[test]
677 fn it_returns_none_for_invalid_input() {
678 assert!(parse_number("foo").is_none());
679 }
680 }
681
682 mod parse_shorthand_duration {
683 use super::*;
684
685 #[test]
686 fn it_parses_hours() {
687 let before = Local::now();
688 let result = parse_shorthand_duration("48h").unwrap();
689 let after = Local::now();
690
691 let expected_before = before - Duration::hours(48);
692 let expected_after = after - Duration::hours(48);
693
694 assert!(result >= expected_before && result <= expected_after);
695 }
696
697 #[test]
698 fn it_parses_minutes() {
699 let before = Local::now();
700 let result = parse_shorthand_duration("15m").unwrap();
701 let after = Local::now();
702
703 let expected_before = before - Duration::minutes(15);
704 let expected_after = after - Duration::minutes(15);
705
706 assert!(result >= expected_before && result <= expected_after);
707 }
708
709 #[test]
710 fn it_returns_none_for_invalid_input() {
711 assert!(parse_shorthand_duration("not valid").is_none());
712 }
713 }
714
715 mod parse_time_only {
716 use pretty_assertions::assert_eq;
717
718 use super::*;
719
720 #[test]
721 fn it_resolves_bare_time_to_today() {
722 let result = parse_time_only("3pm").unwrap();
723
724 assert_eq!(result.date_naive(), Local::now().date_naive());
725 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
726 }
727
728 #[test]
729 fn it_resolves_future_time_to_today() {
730 let result = parse_time_only("11:59pm").unwrap();
731
732 assert_eq!(result.date_naive(), Local::now().date_naive());
733 assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
734 }
735 }
736
737 mod parse_weekday {
738 use pretty_assertions::assert_eq;
739
740 use super::*;
741
742 #[test]
743 fn it_parses_abbreviations() {
744 assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
745 assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
746 assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
747 assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
748 assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
749 assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
750 assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
751 }
752
753 #[test]
754 fn it_returns_none_for_invalid_input() {
755 assert!(parse_weekday("xyz").is_none());
756 }
757 }
758
759 mod resolve_time_expression {
760 use pretty_assertions::assert_eq;
761
762 use super::*;
763
764 #[test]
765 fn it_parses_12_hour_with_minutes() {
766 let result = resolve_time_expression("3:30pm").unwrap();
767
768 assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
769 }
770
771 #[test]
772 fn it_parses_12_hour_without_minutes() {
773 let result = resolve_time_expression("3pm").unwrap();
774
775 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
776 }
777
778 #[test]
779 fn it_parses_12am_as_midnight() {
780 let result = resolve_time_expression("12am").unwrap();
781
782 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
783 }
784
785 #[test]
786 fn it_parses_12pm_as_noon() {
787 let result = resolve_time_expression("12pm").unwrap();
788
789 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
790 }
791
792 #[test]
793 fn it_parses_24_hour() {
794 let result = resolve_time_expression("15:00").unwrap();
795
796 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
797 }
798
799 #[test]
800 fn it_parses_midnight() {
801 let result = resolve_time_expression("midnight").unwrap();
802
803 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
804 }
805
806 #[test]
807 fn it_parses_noon() {
808 let result = resolve_time_expression("noon").unwrap();
809
810 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
811 }
812
813 #[test]
814 fn it_rejects_invalid_hour() {
815 assert!(resolve_time_expression("25:00").is_none());
816 }
817
818 #[test]
819 fn it_returns_none_for_invalid_input() {
820 assert!(resolve_time_expression("not a time").is_none());
821 }
822 }
823
824 mod resolve_weekday {
825 use pretty_assertions::assert_eq;
826
827 use super::*;
828
829 #[test]
830 fn it_defaults_bare_weekday_to_past() {
831 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, None);
833
834 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
835 }
836
837 #[test]
838 fn it_resolves_last_to_past() {
839 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("last"));
841
842 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
843 }
844
845 #[test]
846 fn it_resolves_next_to_future() {
847 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("next"));
849
850 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
851 }
852
853 #[test]
854 fn it_resolves_same_day_last_to_one_week_ago() {
855 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("last"));
857
858 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
859 }
860
861 #[test]
862 fn it_resolves_same_day_next_to_one_week_ahead() {
863 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("next"));
865
866 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
867 }
868 }
869
870 mod beginning_of_day {
871 use super::*;
872
873 #[test]
874 fn it_returns_midnight_for_normal_dates() {
875 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
876 let result = beginning_of_day(date);
877
878 assert_eq!(result.date_naive(), date);
879 }
880
881 #[test]
882 fn it_does_not_panic_on_dst_gap_dates() {
883 let dates = [
887 NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
888 NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
889 NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
890 ];
891 for date in &dates {
892 let result = beginning_of_day(*date);
893 assert_eq!(result.date_naive(), *date);
894 }
895 }
896 }
897}