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>> {
306 let time = resolve_time_expression(input)?;
307 let now = Local::now();
308 apply_time_to_date(now, time)
309}
310
311fn parse_weekday(s: &str) -> Option<Weekday> {
313 match s {
314 s if s.starts_with("mon") => Some(Weekday::Mon),
315 s if s.starts_with("tue") => Some(Weekday::Tue),
316 s if s.starts_with("wed") => Some(Weekday::Wed),
317 s if s.starts_with("thu") => Some(Weekday::Thu),
318 s if s.starts_with("fri") => Some(Weekday::Fri),
319 s if s.starts_with("sat") => Some(Weekday::Sat),
320 s if s.starts_with("sun") => Some(Weekday::Sun),
321 _ => None,
322 }
323}
324
325fn resolve_time_expression(input: &str) -> Option<NaiveTime> {
328 match input {
329 "noon" => return NaiveTime::from_hms_opt(12, 0, 0),
330 "midnight" => return NaiveTime::from_hms_opt(0, 0, 0),
331 _ => {}
332 }
333
334 if let Some(caps) = RE_TIME_12H.captures(input) {
336 let mut hour: u32 = caps[1].parse().ok()?;
337 let min: u32 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
338 let period = &caps[3];
339
340 if hour > 12 || min > 59 {
341 return None;
342 }
343
344 if period == "am" && hour == 12 {
345 hour = 0;
346 } else if period == "pm" && hour != 12 {
347 hour += 12;
348 }
349
350 return NaiveTime::from_hms_opt(hour, min, 0);
351 }
352
353 if let Some(caps) = RE_TIME_24H.captures(input) {
355 let hour: u32 = caps[1].parse().ok()?;
356 let min: u32 = caps[2].parse().ok()?;
357
358 if hour > 23 || min > 59 {
359 return None;
360 }
361
362 return NaiveTime::from_hms_opt(hour, min, 0);
363 }
364
365 None
366}
367
368fn resolve_weekday(now: DateTime<Local>, target: Weekday, direction: Option<&str>) -> DateTime<Local> {
371 let current = now.weekday();
372 let current_num = current.num_days_from_monday() as i64;
373 let target_num = target.num_days_from_monday() as i64;
374
375 match direction {
376 Some("next") => {
377 let d = target_num - current_num;
378 let diff = if d <= 0 { d + 7 } else { d };
379 now + Duration::days(diff)
380 }
381 Some("this") => {
382 let d = target_num - current_num;
385 if d >= 0 {
386 now + Duration::days(d)
387 } else {
388 now - Duration::days(-d)
389 }
390 }
391 _ => {
392 let d = current_num - target_num;
395 let diff = if d <= 0 { d + 7 } else { d };
396 now - Duration::days(diff)
397 }
398 }
399}
400
401#[cfg(test)]
402mod test {
403 use super::*;
404
405 mod beginning_of_day {
406 use super::*;
407
408 #[test]
409 fn it_does_not_panic_on_dst_gap_dates() {
410 let dates = [
414 NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
415 NaiveDate::from_ymd_opt(2024, 10, 6).unwrap(),
416 NaiveDate::from_ymd_opt(2019, 11, 3).unwrap(),
417 ];
418 for date in &dates {
419 let result = beginning_of_day(*date);
420 assert_eq!(result.date_naive(), *date);
421 }
422 }
423
424 #[test]
425 fn it_returns_midnight_for_normal_dates() {
426 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
427 let result = beginning_of_day(date);
428
429 assert_eq!(result.date_naive(), date);
430 }
431 }
432
433 mod chronify {
434 use pretty_assertions::assert_eq;
435
436 use super::*;
437
438 #[test]
439 fn it_parses_absolute_iso_date() {
440 let result = chronify("2024-03-15").unwrap();
441
442 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
443 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
444 }
445
446 #[test]
447 fn it_parses_absolute_iso_datetime() {
448 let result = chronify("2024-03-15 14:30").unwrap();
449
450 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
451 assert_eq!(result.time(), NaiveTime::from_hms_opt(14, 30, 0).unwrap());
452 }
453
454 #[test]
455 fn it_parses_absolute_us_long_date() {
456 let result = chronify("03/15/2024").unwrap();
457
458 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
459 }
460
461 #[test]
462 fn it_parses_absolute_us_short_date() {
463 let result = chronify("03/15/24").unwrap();
464
465 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
466 }
467
468 #[test]
469 fn it_parses_bare_abbreviated_day_name() {
470 let result = chronify("fri").unwrap();
471
472 assert_eq!(result.weekday(), Weekday::Fri);
473 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
474 }
475
476 #[test]
477 fn it_parses_bare_full_day_name() {
478 let result = chronify("friday").unwrap();
479
480 assert_eq!(result.weekday(), Weekday::Fri);
481 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
482 }
483
484 #[test]
485 fn it_parses_combined_day_of_week_with_time() {
486 let result = chronify("yesterday 3pm").unwrap();
487 let expected_date = (Local::now() - Duration::days(1)).date_naive();
488
489 assert_eq!(result.date_naive(), expected_date);
490 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
491 }
492
493 #[test]
494 fn it_parses_combined_with_24h_time() {
495 let result = chronify("tomorrow 15:00").unwrap();
496 let expected_date = (Local::now() + Duration::days(1)).date_naive();
497
498 assert_eq!(result.date_naive(), expected_date);
499 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
500 }
501
502 #[test]
503 fn it_parses_combined_with_at_keyword() {
504 let result = chronify("yesterday at noon").unwrap();
505 let expected_date = (Local::now() - Duration::days(1)).date_naive();
506
507 assert_eq!(result.date_naive(), expected_date);
508 assert_eq!(result.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
509 }
510
511 #[test]
512 fn it_parses_now() {
513 let before = Local::now();
514 let result = chronify("now").unwrap();
515 let after = Local::now();
516
517 assert!(result >= before && result <= after);
518 }
519
520 #[test]
521 fn it_parses_shorthand_duration_hours() {
522 let before = Local::now();
523 let result = chronify("24h").unwrap();
524 let after = Local::now();
525
526 let expected_before = before - Duration::hours(24);
527 let expected_after = after - Duration::hours(24);
528
529 assert!(result >= expected_before && result <= expected_after);
530 }
531
532 #[test]
533 fn it_parses_shorthand_duration_minutes() {
534 let before = Local::now();
535 let result = chronify("30m").unwrap();
536 let after = Local::now();
537
538 let expected_before = before - Duration::minutes(30);
539 let expected_after = after - Duration::minutes(30);
540
541 assert!(result >= expected_before && result <= expected_after);
542 }
543
544 #[test]
545 fn it_parses_shorthand_duration_multi_unit() {
546 let before = Local::now();
547 let result = chronify("1d2h").unwrap();
548 let after = Local::now();
549
550 let expected_before = before - Duration::hours(26);
551 let expected_after = after - Duration::hours(26);
552
553 assert!(result >= expected_before && result <= expected_after);
554 }
555
556 #[test]
557 fn it_parses_today() {
558 let result = chronify("today").unwrap();
559
560 assert_eq!(result.date_naive(), Local::now().date_naive());
561 assert_eq!(result.time(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
562 }
563
564 #[test]
565 fn it_parses_tomorrow() {
566 let result = chronify("tomorrow").unwrap();
567 let expected = (Local::now() + Duration::days(1)).date_naive();
568
569 assert_eq!(result.date_naive(), expected);
570 }
571
572 #[test]
573 fn it_parses_yesterday() {
574 let result = chronify("yesterday").unwrap();
575 let expected = (Local::now() - Duration::days(1)).date_naive();
576
577 assert_eq!(result.date_naive(), expected);
578 }
579
580 #[test]
581 fn it_rejects_empty_input() {
582 let err = chronify("").unwrap_err();
583
584 assert!(matches!(err, Error::InvalidTimeExpression(_)));
585 }
586
587 #[test]
588 fn it_rejects_invalid_input() {
589 let err = chronify("not a date").unwrap_err();
590
591 assert!(matches!(err, Error::InvalidTimeExpression(_)));
592 }
593
594 #[test]
595 fn it_parses_thirteen_days_ago() {
596 let result = chronify("thirteen days ago").unwrap();
597 let expected = Local::now() - Duration::days(13);
598
599 assert_eq!(result.date_naive(), expected.date_naive());
600 }
601
602 #[test]
603 fn it_trims_whitespace() {
604 let result = chronify(" today ").unwrap();
605
606 assert_eq!(result.date_naive(), Local::now().date_naive());
607 }
608 }
609
610 mod parse_ago {
611 use pretty_assertions::assert_eq;
612
613 use super::*;
614
615 #[test]
616 fn it_parses_days_ago() {
617 let now = Local::now();
618 let result = parse_ago("3 days ago", now).unwrap();
619
620 assert_eq!(result.date_naive(), (now - Duration::days(3)).date_naive());
621 }
622
623 #[test]
624 fn it_parses_hours_ago() {
625 let now = Local::now();
626 let result = parse_ago("2 hours ago", now).unwrap();
627 let expected = now - Duration::hours(2);
628
629 assert!((result - expected).num_seconds().abs() < 1);
630 }
631
632 #[test]
633 fn it_parses_minutes_shorthand() {
634 let now = Local::now();
635 let result = parse_ago("30m ago", now).unwrap();
636 let expected = now - Duration::minutes(30);
637
638 assert!((result - expected).num_seconds().abs() < 1);
639 }
640
641 #[test]
642 fn it_parses_weeks_ago() {
643 let now = Local::now();
644 let result = parse_ago("2 weeks ago", now).unwrap();
645
646 assert_eq!(result.date_naive(), (now - Duration::weeks(2)).date_naive());
647 }
648
649 #[test]
650 fn it_parses_written_numbers() {
651 let now = Local::now();
652 let result = parse_ago("one hour ago", now).unwrap();
653 let expected = now - Duration::hours(1);
654
655 assert!((result - expected).num_seconds().abs() < 1);
656 }
657
658 #[test]
659 fn it_parses_written_teen_numbers() {
660 let now = Local::now();
661 let result = parse_ago("thirteen days ago", now).unwrap();
662
663 assert_eq!(result.date_naive(), (now - Duration::days(13)).date_naive());
664 }
665
666 #[test]
667 fn it_returns_none_for_invalid_input() {
668 let now = Local::now();
669
670 assert!(parse_ago("not valid", now).is_none());
671 }
672
673 #[test]
674 fn it_subtracts_calendar_months() {
675 let now = Local.with_ymd_and_hms(2024, 3, 31, 12, 0, 0).unwrap();
676 let result = parse_ago("1 month ago", now).unwrap();
677
678 assert_eq!(result.month(), 2);
680 assert_eq!(result.day(), 29);
681 }
682
683 #[test]
684 fn it_clamps_month_to_last_day() {
685 let now = Local.with_ymd_and_hms(2025, 3, 31, 12, 0, 0).unwrap();
686 let result = parse_ago("1 month ago", now).unwrap();
687
688 assert_eq!(result.month(), 2);
690 assert_eq!(result.day(), 28);
691 }
692 }
693
694 mod parse_day_of_week {
695 use pretty_assertions::assert_eq;
696
697 use super::*;
698
699 #[test]
700 fn it_parses_abbreviations() {
701 for abbr in &["mon", "tue", "wed", "thu", "fri", "sat", "sun"] {
702 let result = parse_day_of_week(abbr);
703 assert!(result.is_some(), "parse_day_of_week should parse abbreviation: {abbr}");
704 }
705 }
706
707 #[test]
708 fn it_parses_alternate_abbreviations() {
709 for abbr in &["tues", "weds", "thur", "thurs"] {
710 let result = parse_day_of_week(abbr);
711 assert!(
712 result.is_some(),
713 "parse_day_of_week should parse alternate abbreviation: {abbr}"
714 );
715 }
716 }
717
718 #[test]
719 fn it_parses_full_day_names() {
720 for name in &[
721 "monday",
722 "tuesday",
723 "wednesday",
724 "thursday",
725 "friday",
726 "saturday",
727 "sunday",
728 ] {
729 let result = parse_day_of_week(name);
730 assert!(result.is_some(), "parse_day_of_week should parse full name: {name}");
731 }
732 }
733
734 #[test]
735 fn it_parses_full_names_with_direction() {
736 let result = parse_day_of_week("last friday");
737 assert!(result.is_some(), "parse_day_of_week should parse 'last friday'");
738
739 let result = parse_day_of_week("next monday");
740 assert!(result.is_some(), "parse_day_of_week should parse 'next monday'");
741 }
742
743 #[test]
744 fn it_resolves_bare_day_to_most_recent_past() {
745 let result = parse_day_of_week("friday").unwrap();
746 let now = Local::now();
747
748 assert!(result <= now, "bare day name should resolve to a past date");
750
751 let cutoff = now - Duration::days(8);
754 assert!(
755 result > cutoff,
756 "bare day name should resolve to within the last 7 days"
757 );
758
759 assert_eq!(result.weekday(), Weekday::Fri);
761 }
762 }
763
764 mod parse_number {
765 use pretty_assertions::assert_eq;
766
767 use super::*;
768
769 #[test]
770 fn it_parses_a_as_one() {
771 assert_eq!(parse_number("a"), Some(1));
772 assert_eq!(parse_number("an"), Some(1));
773 }
774
775 #[test]
776 fn it_parses_digits() {
777 assert_eq!(parse_number("42"), Some(42));
778 }
779
780 #[test]
781 fn it_parses_written_numbers() {
782 assert_eq!(parse_number("one"), Some(1));
783 assert_eq!(parse_number("six"), Some(6));
784 assert_eq!(parse_number("twelve"), Some(12));
785 }
786
787 #[test]
788 fn it_parses_teen_numbers() {
789 assert_eq!(parse_number("thirteen"), Some(13));
790 assert_eq!(parse_number("fourteen"), Some(14));
791 assert_eq!(parse_number("fifteen"), Some(15));
792 assert_eq!(parse_number("sixteen"), Some(16));
793 assert_eq!(parse_number("seventeen"), Some(17));
794 assert_eq!(parse_number("eighteen"), Some(18));
795 assert_eq!(parse_number("nineteen"), Some(19));
796 }
797
798 #[test]
799 fn it_parses_twenty_and_thirty() {
800 assert_eq!(parse_number("twenty"), Some(20));
801 assert_eq!(parse_number("thirty"), Some(30));
802 }
803
804 #[test]
805 fn it_returns_none_for_invalid_input() {
806 assert!(parse_number("foo").is_none());
807 }
808 }
809
810 mod parse_shorthand_duration {
811 use super::*;
812
813 #[test]
814 fn it_parses_hours() {
815 let before = Local::now();
816 let result = parse_shorthand_duration("48h").unwrap();
817 let after = Local::now();
818
819 let expected_before = before - Duration::hours(48);
820 let expected_after = after - Duration::hours(48);
821
822 assert!(result >= expected_before && result <= expected_after);
823 }
824
825 #[test]
826 fn it_parses_minutes() {
827 let before = Local::now();
828 let result = parse_shorthand_duration("15m").unwrap();
829 let after = Local::now();
830
831 let expected_before = before - Duration::minutes(15);
832 let expected_after = after - Duration::minutes(15);
833
834 assert!(result >= expected_before && result <= expected_after);
835 }
836
837 #[test]
838 fn it_returns_none_for_invalid_input() {
839 assert!(parse_shorthand_duration("not valid").is_none());
840 }
841 }
842
843 mod parse_time_only {
844 use pretty_assertions::assert_eq;
845
846 use super::*;
847
848 #[test]
849 fn it_resolves_bare_time_to_today() {
850 let result = parse_time_only("3pm").unwrap();
851
852 assert_eq!(result.date_naive(), Local::now().date_naive());
853 assert_eq!(result.time(), NaiveTime::from_hms_opt(15, 0, 0).unwrap());
854 }
855
856 #[test]
857 fn it_resolves_future_time_to_today() {
858 let result = parse_time_only("11:59pm").unwrap();
859
860 assert_eq!(result.date_naive(), Local::now().date_naive());
861 assert_eq!(result.time(), NaiveTime::from_hms_opt(23, 59, 0).unwrap());
862 }
863 }
864
865 mod parse_weekday {
866 use pretty_assertions::assert_eq;
867
868 use super::*;
869
870 #[test]
871 fn it_parses_abbreviations() {
872 assert_eq!(parse_weekday("mon"), Some(Weekday::Mon));
873 assert_eq!(parse_weekday("tue"), Some(Weekday::Tue));
874 assert_eq!(parse_weekday("wed"), Some(Weekday::Wed));
875 assert_eq!(parse_weekday("thu"), Some(Weekday::Thu));
876 assert_eq!(parse_weekday("fri"), Some(Weekday::Fri));
877 assert_eq!(parse_weekday("sat"), Some(Weekday::Sat));
878 assert_eq!(parse_weekday("sun"), Some(Weekday::Sun));
879 }
880
881 #[test]
882 fn it_returns_none_for_invalid_input() {
883 assert!(parse_weekday("xyz").is_none());
884 }
885 }
886
887 mod resolve_time_expression {
888 use pretty_assertions::assert_eq;
889
890 use super::*;
891
892 #[test]
893 fn it_parses_12_hour_with_minutes() {
894 let result = resolve_time_expression("3:30pm").unwrap();
895
896 assert_eq!(result, NaiveTime::from_hms_opt(15, 30, 0).unwrap());
897 }
898
899 #[test]
900 fn it_parses_12_hour_without_minutes() {
901 let result = resolve_time_expression("3pm").unwrap();
902
903 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
904 }
905
906 #[test]
907 fn it_parses_12am_as_midnight() {
908 let result = resolve_time_expression("12am").unwrap();
909
910 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
911 }
912
913 #[test]
914 fn it_parses_12pm_as_noon() {
915 let result = resolve_time_expression("12pm").unwrap();
916
917 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
918 }
919
920 #[test]
921 fn it_parses_24_hour() {
922 let result = resolve_time_expression("15:00").unwrap();
923
924 assert_eq!(result, NaiveTime::from_hms_opt(15, 0, 0).unwrap());
925 }
926
927 #[test]
928 fn it_parses_midnight() {
929 let result = resolve_time_expression("midnight").unwrap();
930
931 assert_eq!(result, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
932 }
933
934 #[test]
935 fn it_parses_noon() {
936 let result = resolve_time_expression("noon").unwrap();
937
938 assert_eq!(result, NaiveTime::from_hms_opt(12, 0, 0).unwrap());
939 }
940
941 #[test]
942 fn it_rejects_invalid_hour() {
943 assert!(resolve_time_expression("25:00").is_none());
944 }
945
946 #[test]
947 fn it_returns_none_for_invalid_input() {
948 assert!(resolve_time_expression("not a time").is_none());
949 }
950 }
951
952 mod resolve_weekday {
953 use pretty_assertions::assert_eq;
954
955 use super::*;
956
957 #[test]
958 fn it_defaults_bare_weekday_to_past() {
959 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, None);
961
962 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
963 }
964
965 #[test]
966 fn it_resolves_last_to_past() {
967 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("last"));
969
970 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
971 }
972
973 #[test]
974 fn it_resolves_next_to_future() {
975 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("next"));
977
978 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
979 }
980
981 #[test]
982 fn it_resolves_same_day_last_to_one_week_ago() {
983 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("last"));
985
986 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
987 }
988
989 #[test]
990 fn it_resolves_same_day_next_to_one_week_ahead() {
991 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("next"));
993
994 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
995 }
996
997 #[test]
998 fn it_resolves_this_same_day_to_today() {
999 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, Some("this"));
1001
1002 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 17).unwrap());
1003 }
1004
1005 #[test]
1006 fn it_resolves_this_past_day_to_current_week() {
1007 let now = Local.with_ymd_and_hms(2026, 3, 19, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Mon, Some("this"));
1009
1010 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 16).unwrap());
1012 }
1013
1014 #[test]
1015 fn it_resolves_this_future_day_to_current_week() {
1016 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Fri, Some("this"));
1018
1019 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 20).unwrap());
1021 }
1022
1023 #[test]
1024 fn it_resolves_bare_same_day_to_one_week_ago() {
1025 let now = Local.with_ymd_and_hms(2026, 3, 17, 12, 0, 0).unwrap(); let result = resolve_weekday(now, Weekday::Tue, None);
1027
1028 assert_eq!(result.date_naive(), NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
1030 }
1031 }
1032}