Skip to main content

doing_time/
parser.rs

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
23/// Parse a natural language date/time expression into a `DateTime<Local>`.
24///
25/// Supports relative expressions (`now`, `today`, `yesterday`, `2 hours ago`),
26/// day-of-week references (`last monday`, `next friday`), time-only expressions
27/// (`3pm`, `15:00`, `noon`, `midnight`), absolute dates (`2024-01-15`,
28/// `01/15/24`), and combined forms (`yesterday 3pm`, `monday 9:30am`).
29///
30/// Bare times always resolve to today's date.
31pub 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
65/// Apply a `NaiveTime` to a date, returning a `DateTime<Local>`.
66fn 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
70/// Set a `DateTime` to the beginning of its day (midnight).
71fn 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
79/// Parse absolute date expressions: `YYYY-MM-DD`, `YYYY-MM-DD HH:MM`,
80/// `MM/DD/YY`, `MM/DD/YYYY`.
81fn parse_absolute(input: &str) -> Option<DateTime<Local>> {
82  // YYYY-MM-DD HH:MM
83  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  // YYYY-MM-DD
96  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  // MM/DD/YYYY
108  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  // MM/DD/YY
120  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  // MM/DD (short US date, no year — resolve to current year or most recent past)
133  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    // If the date is in the future, use last year
141    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
154/// Parse `N unit(s) ago` expressions. Supports shorthand (`30m ago`, `2h ago`)
155/// and long form (`3 days ago`, `one month ago`).
156fn 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
174/// Parse combined date + time expressions: `yesterday 3pm`, `monday 9:30am`,
175/// `last friday at noon`, `tomorrow 15:00`.
176fn parse_combined(input: &str) -> Option<DateTime<Local>> {
177  // Split on " at " first, then fall back to splitting on last space
178  let (date_part, time_part) = if let Some((d, t)) = input.split_once(" at ") {
179    (d.trim(), t.trim())
180  } else {
181    // Find the time portion at the end: look for a token that resolves as a time
182    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  // Try to resolve the date part
190  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
203/// Parse day-of-week expressions: `monday`, `last tuesday`, `next friday`.
204/// Bare weekday names default to the most recent past occurrence.
205fn 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
215/// Parse a word or digit string as an integer. Supports written-out numbers
216/// (`one` through `twelve`) and plain digits.
217fn 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
235/// Parse relative date expressions: `now`, `today`, `yesterday`, `tomorrow`,
236/// and offset expressions like `2 hours ago`, `30m ago`, `3 days ago`.
237fn 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
251/// Parse a bare duration shorthand (e.g. `24h`, `30m`, `1d2h`) as an offset
252/// into the past from now.
253fn parse_shorthand_duration(input: &str) -> Option<DateTime<Local>> {
254  let duration = parse_duration(input).ok()?;
255  Some(Local::now() - duration)
256}
257
258/// Parse a time-only expression into today's date with the given time.
259/// Supports `noon`, `midnight`, `3pm`, `3:30pm`, `15:00`.
260/// Bare times always resolve to today, matching the original Ruby behavior.
261fn 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
267/// Convert a weekday abbreviation to a `chrono::Weekday`.
268fn 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
281/// Parse a time string into a `NaiveTime`. Supports `noon`, `midnight`,
282/// `3pm`, `3:30pm`, `15:00`.
283fn 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  // 12-hour: 3pm, 3:30pm, 12:00am
291  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  // 24-hour: 15:00, 08:30
310  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
324/// Resolve a weekday relative to `now`. `last` looks back, `next` looks forward,
325/// `None`/`this` defaults to the most recent past occurrence.
326fn 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      // Default to past (same as "last", but same day = 7 days ago)
342      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      // Result should be in the past (or at most today at midnight)
637      assert!(result <= now, "bare day name should resolve to a past date");
638
639      // Result should be within the last 7 days (use 8-day window to account for
640      // same-weekday resolving to 7 days ago at midnight)
641      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      // Result should be a Friday
648      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(); // Tuesday
831      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(); // Tuesday
839      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(); // Tuesday
847      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(); // Tuesday
855      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(); // Tuesday
863      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}