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 subtract_months(dt: DateTime<Local>, months: i64) -> DateTime<Local> {
73 let total_months = dt.year() * 12 + dt.month0() as i32 - months as i32;
74 let target_year = total_months.div_euclid(12);
75 let target_month0 = total_months.rem_euclid(12) as u32;
76 let target_month = target_month0 + 1;
77
78 let max_day = last_day_of_month(target_year, target_month);
80 let day = dt.day().min(max_day);
81
82 let date = NaiveDate::from_ymd_opt(target_year, target_month, day).expect("valid date after month subtraction");
83 let time = dt.time();
84 Local
85 .from_local_datetime(&date.and_time(time))
86 .earliest()
87 .unwrap_or_else(|| beginning_of_day(date))
88}
89
90fn last_day_of_month(year: i32, month: u32) -> u32 {
92 NaiveDate::from_ymd_opt(year, month + 1, 1)
93 .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
94 .pred_opt()
95 .unwrap()
96 .day()
97}
98
99fn beginning_of_day(date: NaiveDate) -> DateTime<Local> {
102 if let Some(dt) = Local.from_local_datetime(&date.and_time(NaiveTime::MIN)).earliest() {
103 return dt;
104 }
105 for hour in 1..=12 {
106 if let Some(dt) = Local
107 .from_local_datetime(&date.and_hms_opt(hour, 0, 0).expect("valid hour 1..=12"))
108 .earliest()
109 {
110 return dt;
111 }
112 }
113 date.and_time(NaiveTime::MIN).and_utc().with_timezone(&Local)
115}
116
117fn parse_absolute(input: &str) -> Option<DateTime<Local>> {
120 if let Some(caps) = RE_ISO_DATETIME.captures(input) {
122 let year: i32 = caps[1].parse().ok()?;
123 let month: u32 = caps[2].parse().ok()?;
124 let day: u32 = caps[3].parse().ok()?;
125 let hour: u32 = caps[4].parse().ok()?;
126 let min: u32 = caps[5].parse().ok()?;
127
128 let date = NaiveDate::from_ymd_opt(year, month, day)?;
129 let time = NaiveTime::from_hms_opt(hour, min, 0)?;
130 return Local.from_local_datetime(&date.and_time(time)).earliest();
131 }
132
133 if let Some(caps) = RE_ISO_DATE.captures(input) {
135 let year: i32 = caps[1].parse().ok()?;
136 let month: u32 = caps[2].parse().ok()?;
137 let day: u32 = caps[3].parse().ok()?;
138
139 let date = NaiveDate::from_ymd_opt(year, month, day)?;
140 return Some(beginning_of_day(date));
141 }
142
143 if let Some(caps) = RE_US_DATE_LONG.captures(input) {
145 let month: u32 = caps[1].parse().ok()?;
146 let day: u32 = caps[2].parse().ok()?;
147 let year: i32 = caps[3].parse().ok()?;
148
149 let date = NaiveDate::from_ymd_opt(year, month, day)?;
150 return Some(beginning_of_day(date));
151 }
152
153 if let Some(caps) = RE_US_DATE_SHORT.captures(input) {
155 let month: u32 = caps[1].parse().ok()?;
156 let day: u32 = caps[2].parse().ok()?;
157 let short_year: i32 = caps[3].parse().ok()?;
158 let year = 2000 + short_year;
159
160 let date = NaiveDate::from_ymd_opt(year, month, day)?;
161 return Some(beginning_of_day(date));
162 }
163
164 if let Some(caps) = RE_US_DATE_NO_YEAR.captures(input) {
166 let month: u32 = caps[1].parse().ok()?;
167 let day: u32 = caps[2].parse().ok()?;
168 let today = Local::now().date_naive();
169
170 for offset in 0..=4 {
172 let y = today.year() - offset;
173 if let Some(date) = NaiveDate::from_ymd_opt(y, month, day)
174 && date <= today
175 {
176 return Some(beginning_of_day(date));
177 }
178 }
179 return None;
180 }
181
182 None
183}
184
185fn parse_ago(input: &str, now: DateTime<Local>) -> Option<DateTime<Local>> {
188 let caps = RE_AGO.captures(input)?;
189
190 let amount = parse_number(&caps[1])?;
191 let unit = &caps[2];
192
193 match unit {
194 u if u.starts_with("mo") => {
195 return Some(subtract_months(now, amount));
196 }
197 _ => {}
198 }
199
200 let duration = match unit {
201 u if u.starts_with("mi") || u == "m" => Duration::minutes(amount),
202 u if u.starts_with('h') => Duration::hours(amount),
203 u if u.starts_with('d') => Duration::days(amount),
204 u if u.starts_with('w') => Duration::weeks(amount),
205 _ => return None,
206 };
207
208 Some(now - duration)
209}
210
211fn parse_combined(input: &str) -> Option<DateTime<Local>> {
214 let (date_part, time_part) = if let Some((d, t)) = input.split_once(" at ") {
216 (d.trim(), t.trim())
217 } else {
218 let last_space = input.rfind(' ')?;
220 let (d, t) = input.split_at(last_space);
221 (d.trim(), t.trim())
222 };
223
224 let time = resolve_time_expression(time_part)?;
225
226 let base_date = if let Some(dt) = parse_relative(date_part) {
228 dt
229 } else if let Some(dt) = parse_day_of_week(date_part) {
230 dt
231 } else {
232 parse_absolute(date_part)?
233 };
234
235 apply_time_to_date(base_date, time)
236}
237
238fn parse_day_of_week(input: &str) -> Option<DateTime<Local>> {
241 let now = Local::now();
242 let caps = RE_DAY_OF_WEEK.captures(input)?;
243
244 let direction = caps.get(1).map(|m| m.as_str());
245 let weekday = parse_weekday(&caps[2])?;
246
247 Some(beginning_of_day(resolve_weekday(now, weekday, direction).date_naive()))
248}
249
250fn parse_number(s: &str) -> Option<i64> {
253 match s {
254 "one" | "a" | "an" => Some(1),
255 "two" => Some(2),
256 "three" => Some(3),
257 "four" => Some(4),
258 "five" => Some(5),
259 "six" => Some(6),
260 "seven" => Some(7),
261 "eight" => Some(8),
262 "nine" => Some(9),
263 "ten" => Some(10),
264 "eleven" => Some(11),
265 "twelve" => Some(12),
266 "thirteen" => Some(13),
267 "fourteen" => Some(14),
268 "fifteen" => Some(15),
269 "sixteen" => Some(16),
270 "seventeen" => Some(17),
271 "eighteen" => Some(18),
272 "nineteen" => Some(19),
273 "twenty" => Some(20),
274 "thirty" => Some(30),
275 _ => s.parse().ok(),
276 }
277}
278
279fn parse_relative(input: &str) -> Option<DateTime<Local>> {
282 let now = Local::now();
283
284 match input {
285 "now" => return Some(now),
286 "today" => return Some(beginning_of_day(now.date_naive())),
287 "yesterday" => return Some(beginning_of_day((now - Duration::days(1)).date_naive())),
288 "tomorrow" => return Some(beginning_of_day((now + Duration::days(1)).date_naive())),
289 _ => {}
290 }
291
292 parse_ago(input, now)
293}
294
295fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
298 let duration = parse_duration(input).ok()?;
299 Some(Local::now() - duration)
300}
301
302fn parse_time_only(input: &str) -> Option<DateTime<Local>> {
307 let time = resolve_time_expression(input)?;
308 let now = Local::now();
309 let result = apply_time_to_date(now, time)?;
310 if result > now {
311 apply_time_to_date(now - Duration::days(1), time)
312 } else {
313 Some(result)
314 }
315}
316
317fn parse_weekday(s: &str) -> Option<Weekday> {
319 match s {
320 s if s.starts_with("mon") => Some(Weekday::Mon),
321 s if s.starts_with("tue") => Some(Weekday::Tue),
322 s if s.starts_with("wed") => Some(Weekday::Wed),
323 s if s.starts_with("thu") => Some(Weekday::Thu),
324 s if s.starts_with("fri") => Some(Weekday::Fri),
325 s if s.starts_with("sat") => Some(Weekday::Sat),
326 s if s.starts_with("sun") => Some(Weekday::Sun),
327 _ => None,
328 }
329}
330
331fn resolve_time_expression(input: &str) -> Option<NaiveTime> {
334 match input {
335 "noon" => return NaiveTime::from_hms_opt(12, 0, 0),
336 "midnight" => return NaiveTime::from_hms_opt(0, 0, 0),
337 _ => {}
338 }
339
340 if let Some(caps) = RE_TIME_12H.captures(input) {
342 let mut hour: u32 = caps[1].parse().ok()?;
343 let min: u32 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
344 let period = &caps[3];
345
346 if hour > 12 || min > 59 {
347 return None;
348 }
349
350 if period == "am" && hour == 12 {
351 hour = 0;
352 } else if period == "pm" && hour != 12 {
353 hour += 12;
354 }
355
356 return NaiveTime::from_hms_opt(hour, min, 0);
357 }
358
359 if let Some(caps) = RE_TIME_24H.captures(input) {
361 let hour: u32 = caps[1].parse().ok()?;
362 let min: u32 = caps[2].parse().ok()?;
363
364 if hour > 23 || min > 59 {
365 return None;
366 }
367
368 return NaiveTime::from_hms_opt(hour, min, 0);
369 }
370
371 None
372}
373
374fn resolve_weekday(now: DateTime<Local>, target: Weekday, direction: Option<&str>) -> DateTime<Local> {
377 let current = now.weekday();
378 let current_num = current.num_days_from_monday() as i64;
379 let target_num = target.num_days_from_monday() as i64;
380
381 match direction {
382 Some("next") => {
383 let d = target_num - current_num;
384 let diff = if d <= 0 { d + 7 } else { d };
385 now + Duration::days(diff)
386 }
387 Some("this") => {
388 let d = target_num - current_num;
391 if d >= 0 {
392 now + Duration::days(d)
393 } else {
394 now - Duration::days(-d)
395 }
396 }
397 _ => {
398 let d = current_num - target_num;
401 let diff = if d <= 0 { d + 7 } else { d };
402 now - Duration::days(diff)
403 }
404 }
405}
406
407#[cfg(test)]
408mod test {
409 use super::*;
410
411 mod beginning_of_day {
412 use super::*;
413
414 #[test]
415 fn it_does_not_panic_on_dst_gap_dates() {
416 let dates = [
420 NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
421 NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
422 NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
423 ];
424 for date in &dates {
425 let result = beginning_of_day(*date);
426 assert_eq!(result.date_naive(), *date);
427 }
428 }
429
430 #[test]
431 fn it_returns_midnight_for_normal_dates() {
432 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
433 let result = beginning_of_day(date);
434
435 assert_eq!(result.date_naive(), date);
436 }
437 }
438
439 mod chronify {
440 use pretty_assertions::assert_eq;
441
442 use super::*;
443
444 #[test]
445 fn it_parses_absolute_iso_date() {
446 let result = chronify("2024-03-15").unwrap();
447
448 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
449 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
450 }
451
452 #[test]
453 fn it_parses_absolute_iso_datetime() {
454 let result = chronify("2024-03-15 14:30").unwrap();
455
456 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
457 assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
458 }
459
460 #[test]
461 fn it_parses_absolute_us_long_date() {
462 let result = chronify("03/15/2024").unwrap();
463
464 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
465 }
466
467 #[test]
468 fn it_parses_absolute_us_short_date() {
469 let result = chronify("03/15/24").unwrap();
470
471 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
472 }
473
474 #[test]
475 fn it_parses_bare_abbreviated_day_name() {
476 let result = chronify("fri").unwrap();
477
478 assert_eq!(result.weekday(), Weekday::Fri);
479 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
480 }
481
482 #[test]
483 fn it_parses_bare_full_day_name() {
484 let result = chronify("friday").unwrap();
485
486 assert_eq!(result.weekday(), Weekday::Fri);
487 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
488 }
489
490 #[test]
491 fn it_parses_combined_day_of_week_with_time() {
492 let result = chronify("yesterday 3pm").unwrap();
493 let expected_date = (Local::now() - Duration::days(1)).date_naive();
494
495 assert_eq!(result.date_naive(), expected_date);
496 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
497 }
498
499 #[test]
500 fn it_parses_combined_with_24h_time() {
501 let result = chronify("tomorrow 15:00").unwrap();
502 let expected_date = (Local::now() + Duration::days(1)).date_naive();
503
504 assert_eq!(result.date_naive(), expected_date);
505 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
506 }
507
508 #[test]
509 fn it_parses_combined_with_at_keyword() {
510 let result = chronify("yesterday at noon").unwrap();
511 let expected_date = (Local::now() - Duration::days(1)).date_naive();
512
513 assert_eq!(result.date_naive(), expected_date);
514 assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
515 }
516
517 #[test]
518 fn it_parses_now() {
519 let before = Local::now();
520 let result = chronify("now").unwrap();
521 let after = Local::now();
522
523 assert!(result >= before && result <= after);
524 }
525
526 #[test]
527 fn it_parses_shorthand_duration_hours() {
528 let before = Local::now();
529 let result = chronify("24h").unwrap();
530 let after = Local::now();
531
532 let expected_before = before - Duration::hours(24);
533 let expected_after = after - Duration::hours(24);
534
535 assert!(result >= expected_before && result <= expected_after);
536 }
537
538 #[test]
539 fn it_parses_shorthand_duration_minutes() {
540 let before = Local::now();
541 let result = chronify("30m").unwrap();
542 let after = Local::now();
543
544 let expected_before = before - Duration::minutes(30);
545 let expected_after = after - Duration::minutes(30);
546
547 assert!(result >= expected_before && result <= expected_after);
548 }
549
550 #[test]
551 fn it_parses_shorthand_duration_multi_unit() {
552 let before = Local::now();
553 let result = chronify("1d2h").unwrap();
554 let after = Local::now();
555
556 let expected_before = before - Duration::hours(26);
557 let expected_after = after - Duration::hours(26);
558
559 assert!(result >= expected_before && result <= expected_after);
560 }
561
562 #[test]
563 fn it_parses_today() {
564 let result = chronify("today").unwrap();
565
566 assert_eq!(result.date_naive(), Local::now().date_naive());
567 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
568 }
569
570 #[test]
571 fn it_parses_tomorrow() {
572 let result = chronify("tomorrow").unwrap();
573 let expected = (Local::now() + Duration::days(1)).date_naive();
574
575 assert_eq!(result.date_naive(), expected);
576 }
577
578 #[test]
579 fn it_parses_yesterday() {
580 let result = chronify("yesterday").unwrap();
581 let expected = (Local::now() - Duration::days(1)).date_naive();
582
583 assert_eq!(result.date_naive(), expected);
584 }
585
586 #[test]
587 fn it_rejects_empty_input() {
588 let err = chronify("").unwrap_err();
589
590 assert!(matches!(err, Error::InvalidTimeExpression(_)));
591 }
592
593 #[test]
594 fn it_rejects_invalid_input() {
595 let err = chronify("not a date").unwrap_err();
596
597 assert!(matches!(err, Error::InvalidTimeExpression(_)));
598 }
599
600 #[test]
601 fn it_parses_thirteen_days_ago() {
602 let result = chronify("thirteen days ago").unwrap();
603 let expected = Local::now() - Duration::days(13);
604
605 assert_eq!(result.date_naive(), expected.date_naive());
606 }
607
608 #[test]
609 fn it_trims_whitespace() {
610 let result = chronify(" today ").unwrap();
611
612 assert_eq!(result.date_naive(), Local::now().date_naive());
613 }
614 }
615
616 mod parse_ago {
617 use pretty_assertions::assert_eq;
618
619 use super::*;
620
621 #[test]
622 fn it_parses_days_ago() {
623 let now = Local::now();
624 let result = parse_ago("3 days ago", now).unwrap();
625
626 assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
627 }
628
629 #[test]
630 fn it_parses_hours_ago() {
631 let now = Local::now();
632 let result = parse_ago("2 hours ago", now).unwrap();
633 let expected = now - Duration::hours(2);
634
635 assert!((result - expected).num_seconds().abs() < 1);
636 }
637
638 #[test]
639 fn it_parses_minutes_shorthand() {
640 let now = Local::now();
641 let result = parse_ago("30m ago", now).unwrap();
642 let expected = now - Duration::minutes(30);
643
644 assert!((result - expected).num_seconds().abs() < 1);
645 }
646
647 #[test]
648 fn it_parses_weeks_ago() {
649 let now = Local::now();
650 let result = parse_ago("2 weeks ago", now).unwrap();
651
652 assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
653 }
654
655 #[test]
656 fn it_parses_written_numbers() {
657 let now = Local::now();
658 let result = parse_ago("one hour ago", now).unwrap();
659 let expected = now - Duration::hours(1);
660
661 assert!((result - expected).num_seconds().abs() < 1);
662 }
663
664 #[test]
665 fn it_parses_written_teen_numbers() {
666 let now = Local::now();
667 let result = parse_ago("thirteen days ago", now).unwrap();
668
669 assert_eq!(result.date_naive(), (now - Duration::days(13)).date_naive());
670 }
671
672 #[test]
673 fn it_returns_none_for_invalid_input() {
674 let now = Local::now();
675
676 assert!(parse_ago("not valid", now).is_none());
677 }
678
679 #[test]
680 fn it_subtracts_calendar_months() {
681 let now = Local.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap();
682 let result = parse_ago("1 month ago", now).unwrap();
683
684 assert_eq!(result.month(), 2);
686 assert_eq!(result.day(), 29);
687 }
688
689 #[test]
690 fn it_clamps_month_to_last_day() {
691 let now = Local.with_ymd_and_hms(2025, 3, 31, 12, 0, 0).unwrap();
692 let result = parse_ago("1 month ago", now).unwrap();
693
694 assert_eq!(result.month(), 2);
696 assert_eq!(result.day(), 28);
697 }
698 }
699
700 mod parse_day_of_week {
701 use pretty_assertions::assert_eq;
702
703 use super::*;
704
705 #[test]
706 fn it_parses_abbreviations() {
707 for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
708 let result = parse_day_of_week(abbr);
709 assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
710 }
711 }
712
713 #[test]
714 fn it_parses_alternate_abbreviations() {
715 for abbr in &["tues", "weds", "thur", "thurs"] {
716 let result = parse_day_of_week(abbr);
717 assert!(
718 result.is_some(),
719 "parse_day_of_week should parse alternate abbreviation: {abbr}"
720 );
721 }
722 }
723
724 #[test]
725 fn it_parses_full_day_names() {
726 for name in &[
727 "monday",
728 "tuesday",
729 "wednesday",
730 "thursday",
731 "friday",
732 "saturday",
733 "sunday",
734 ] {
735 let result = parse_day_of_week(name);
736 assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
737 }
738 }
739
740 #[test]
741 fn it_parses_full_names_with_direction() {
742 let result = parse_day_of_week("last friday");
743 assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
744
745 let result = parse_day_of_week("next monday");
746 assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
747 }
748
749 #[test]
750 fn it_resolves_bare_day_to_most_recent_past() {
751 let result = parse_day_of_week("friday").unwrap();
752 let now = Local::now();
753
754 assert!(result <= now, "bare day name should resolve to a past date");
756
757 let cutoff = now - Duration::days(8);
760 assert!(
761 result > cutoff,
762 "bare day name should resolve to within the last 7 days"
763 );
764
765 assert_eq!(result.weekday(), Weekday::Fri);
767 }
768 }
769
770 mod parse_number {
771 use pretty_assertions::assert_eq;
772
773 use super::*;
774
775 #[test]
776 fn it_parses_a_as_one() {
777 assert_eq!(parse_number("a"), Some(1));
778 assert_eq!(parse_number("an"), Some(1));
779 }
780
781 #[test]
782 fn it_parses_digits() {
783 assert_eq!(parse_number("42"), Some(42));
784 }
785
786 #[test]
787 fn it_parses_written_numbers() {
788 assert_eq!(parse_number("one"), Some(1));
789 assert_eq!(parse_number("six"), Some(6));
790 assert_eq!(parse_number("twelve"), Some(12));
791 }
792
793 #[test]
794 fn it_parses_teen_numbers() {
795 assert_eq!(parse_number("thirteen"), Some(13));
796 assert_eq!(parse_number("fourteen"), Some(14));
797 assert_eq!(parse_number("fifteen"), Some(15));
798 assert_eq!(parse_number("sixteen"), Some(16));
799 assert_eq!(parse_number("seventeen"), Some(17));
800 assert_eq!(parse_number("eighteen"), Some(18));
801 assert_eq!(parse_number("nineteen"), Some(19));
802 }
803
804 #[test]
805 fn it_parses_twenty_and_thirty() {
806 assert_eq!(parse_number("twenty"), Some(20));
807 assert_eq!(parse_number("thirty"), Some(30));
808 }
809
810 #[test]
811 fn it_returns_none_for_invalid_input() {
812 assert!(parse_number("foo").is_none());
813 }
814 }
815
816 mod parse_shorthand_duration {
817 use super::*;
818
819 #[test]
820 fn it_parses_hours() {
821 let before = Local::now();
822 let result = parse_shorthand_duration("48h").unwrap();
823 let after = Local::now();
824
825 let expected_before = before - Duration::hours(48);
826 let expected_after = after - Duration::hours(48);
827
828 assert!(result >= expected_before && result <= expected_after);
829 }
830
831 #[test]
832 fn it_parses_minutes() {
833 let before = Local::now();
834 let result = parse_shorthand_duration("15m").unwrap();
835 let after = Local::now();
836
837 let expected_before = before - Duration::minutes(15);
838 let expected_after = after - Duration::minutes(15);
839
840 assert!(result >= expected_before && result <= expected_after);
841 }
842
843 #[test]
844 fn it_returns_none_for_invalid_input() {
845 assert!(parse_shorthand_duration("not valid").is_none());
846 }
847 }
848
849 mod parse_time_only {
850 use pretty_assertions::assert_eq;
851
852 use super::*;
853
854 #[test]
855 fn it_resolves_bare_time_to_today() {
856 let result = parse_time_only("midnight").unwrap();
857
858 assert_eq!(result.date_naive(), Local::now().date_naive());
859 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
860 }
861
862 #[test]
863 fn it_resolves_future_time_to_previous_day() {
864 let result = parse_time_only("11:59pm").unwrap();
865 let yesterday = (Local::now() - Duration::days(1)).date_naive();
866
867 assert_eq!(result.date_naive(), yesterday);
868 assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
869 }
870 }
871
872 mod parse_weekday {
873 use pretty_assertions::assert_eq;
874
875 use super::*;
876
877 #[test]
878 fn it_parses_abbreviations() {
879 assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
880 assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
881 assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
882 assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
883 assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
884 assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
885 assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
886 }
887
888 #[test]
889 fn it_returns_none_for_invalid_input() {
890 assert!(parse_weekday("xyz").is_none());
891 }
892 }
893
894 mod resolve_time_expression {
895 use pretty_assertions::assert_eq;
896
897 use super::*;
898
899 #[test]
900 fn it_parses_12_hour_with_minutes() {
901 let result = resolve_time_expression("3:30pm").unwrap();
902
903 assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
904 }
905
906 #[test]
907 fn it_parses_12_hour_without_minutes() {
908 let result = resolve_time_expression("3pm").unwrap();
909
910 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
911 }
912
913 #[test]
914 fn it_parses_12am_as_midnight() {
915 let result = resolve_time_expression("12am").unwrap();
916
917 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
918 }
919
920 #[test]
921 fn it_parses_12pm_as_noon() {
922 let result = resolve_time_expression("12pm").unwrap();
923
924 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
925 }
926
927 #[test]
928 fn it_parses_24_hour() {
929 let result = resolve_time_expression("15:00").unwrap();
930
931 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
932 }
933
934 #[test]
935 fn it_parses_midnight() {
936 let result = resolve_time_expression("midnight").unwrap();
937
938 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
939 }
940
941 #[test]
942 fn it_parses_noon() {
943 let result = resolve_time_expression("noon").unwrap();
944
945 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
946 }
947
948 #[test]
949 fn it_rejects_invalid_hour() {
950 assert!(resolve_time_expression("25:00").is_none());
951 }
952
953 #[test]
954 fn it_returns_none_for_invalid_input() {
955 assert!(resolve_time_expression("not a time").is_none());
956 }
957 }
958
959 mod resolve_weekday {
960 use pretty_assertions::assert_eq;
961
962 use super::*;
963
964 #[test]
965 fn it_defaults_bare_weekday_to_past() {
966 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, None);
968
969 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
970 }
971
972 #[test]
973 fn it_resolves_last_to_past() {
974 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("last"));
976
977 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
978 }
979
980 #[test]
981 fn it_resolves_next_to_future() {
982 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("next"));
984
985 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
986 }
987
988 #[test]
989 fn it_resolves_same_day_last_to_one_week_ago() {
990 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("last"));
992
993 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
994 }
995
996 #[test]
997 fn it_resolves_same_day_next_to_one_week_ahead() {
998 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("next"));
1000
1001 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
1002 }
1003
1004 #[test]
1005 fn it_resolves_this_same_day_to_today() {
1006 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("this"));
1008
1009 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
1010 }
1011
1012 #[test]
1013 fn it_resolves_this_past_day_to_current_week() {
1014 let now = Local.with_ymd_and_hms(2026, 3, 19, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("this"));
1016
1017 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
1019 }
1020
1021 #[test]
1022 fn it_resolves_this_future_day_to_current_week() {
1023 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("this"));
1025
1026 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
1028 }
1029
1030 #[test]
1031 fn it_resolves_bare_same_day_to_one_week_ago() {
1032 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, None);
1034
1035 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
1037 }
1038 }
1039}